Back to JavaScript
JavaScript
medium
mid

What is the difference between ESM and CommonJS modules?

ESM is statically analyzable, async, the standard. CJS is dynamic, sync, Node-historical. Modern code is ESM; CJS lives on for legacy compatibility. Mixing them is the source of many Node interop bugs.

6 min read·~12 min to think through

CommonJS (CJS) is the original Node module system: module.exports / require(). It's dynamicrequire() is a function that runs at call time, returns whatever object the module decided to set. Synchronous — execution blocks until the module's code finishes.

ES Modules (ESM) is the standard: export / import. Static — imports/exports are declared at the top, parseable before execution. Asynchronous — modules can be loaded in parallel; top-level await works.

Why the distinction matters in practice:

  • Tree shaking works well with ESM (static export shape) and barely works with CJS.
  • Top-level await is ESM-only.
  • Dual packages (exports map in package.json) let a library ship both for compatibility, but the same module loaded twice (once CJS, once ESM) causes "instanceof" and singleton bugs.
  • Node interopimport of CJS in ESM works; require of ESM doesn't (synchronously). Use await import().
  • Browsers support ESM natively (<script type="module">); CJS never worked in browsers without a bundler.

Migration tips: prefer "type": "module" for new packages. Author in TS, emit ESM, expose CJS only as a fallback build via exports map.

Code

ts
// CommonJS
const fs = require("fs");
module.exports = { read: () => fs.readFileSync(...) };

// ES Modules
import fs from "node:fs";
export const read = () => fs.readFileSync(...);
export default class { /* ... */ }
Side-by-side syntax
ts
{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types":   "./dist/index.d.ts"
    }
  }
}
Dual-package exports map

Follow-up questions

  • What's a 'dual package hazard' and how do you avoid it?
  • Why can't you `require()` an ESM module synchronously?
  • How does Node decide whether a `.js` file is ESM or CJS?

Common mistakes

  • Mixing default and named imports of a CJS package in TypeScript and being surprised by `interop=true` output.
  • Top-level await in a file Node treats as CJS — throws.
  • Two copies of the same package (CJS + ESM) in a bundle, breaking instanceof checks.

Performance considerations

  • ESM enables aggressive tree shaking. CJS bundles ship more bytes.

Edge cases

  • JSON imports need an import attribute: `import data from './x.json' with { type: 'json' }`.
  • Conditional exports can break older Node — test with the lowest supported version.

Real-world examples

  • TypeScript 5.0+ ships ESM-first; chalk@5 went ESM-only and broke many CJS consumers — a public migration lesson.

Senior engineer discussion

Senior signal: discuss exports/conditions, the dual package hazard, how Node's module resolution algorithm works, and the cost of maintaining a CJS fallback.

Related questions