ESM vs CommonJS — what's the difference?
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.
CommonJS (CJS) is the original Node module system: module.exports / require(). It's dynamic — require() 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
awaitis ESM-only. - Dual packages (
exportsmap 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 interop —
importof CJS in ESM works;requireof ESM doesn't (synchronously). Useawait 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
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.