intermediate

ESM vs CommonJS

Compare static ESM bindings with CommonJS require, package type, interop edges, loading timing, and migration paths.

CommonJS (`require`/`module.exports`) loads synchronously with dynamic resolution; ESM (`import`/`export`) is statically analyzable, asynchronous at top level, and the standard for new Node code.

| Aspect | CommonJS | ESM | |--------|----------|-----| | Loading | Sync `require` | Async module graph | | Exports | Mutable `module.exports` | Live bindings | | Top-level await | No | Yes | | `__dirname` | Built-in | Derive via `import.meta.url` |

`package.json` field `"type": "module"` makes `.js` ESM; without it, `.js` is CommonJS and `.mjs` is ESM. Interop: `import cjs from 'pkg'` often wraps default export; `createRequire` imports CJS from ESM files.

					// ESM file
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
				

On interviews: explain live bindings, why dual packages are painful, and migration strategy (`type`, `.cjs`, `.mjs`, tooling).

Common pitfalls: mixing `require` in ESM without createRequire; assuming `import` hoists like bundlers in all edge cases; circular dependency differences.

The trade-off is balancing simplicity, performance, safety, and operability — name which axis you optimized and what cost you accepted.

Checklist:

  • Set `type` intentionally per package.
  • Know interop bridges (`createRequire`, default import).
  • Align test runner and tsconfig module setting.
  • Avoid publishing both CJS and ESM without exports map.