Topics
v6.0.0
On this page · sections

CommonJS vs ES Modules

Two module systems shape how JavaScript is bundled, tree-shaken, and shipped. Interviewers use this to filter candidates who understand module graphs from those who only know require vs import.

SystemSyntaxWhen exports resolveBundler analysis
CommonJSmodule.exports / require()Runtime, depth-firstDynamic — hard to shake
ESMexport / importParse-time, live bindingsStatic — tree-shakeable

Syntax & Load Timing

Definition

CommonJS is synchronous and runtime: require() runs at the call site and returns a snapshot of module.exports.

ESM is statically parsed: import/export are hoisted, bindings are live between modules, and the engine builds a dependency graph before execution.

// math.cjs module.exports = { add: (a, b) => a + b }; // app.cjs const math = require('./math.cjs'); console.log(math.add(2, 3)); // 5 // math.mjs export function add(a, b) { return a + b; } // app.mjs import { add } from './math.mjs'; console.log(add(2, 3)); // 5
CJS: require() → execute module → snapshot module.exports ESM: parse imports → link live bindings → evaluate in dependency order

In Node, .mjs or "type": "module" loads ESM; .cjs or default .js in CJS packages loads CommonJS.

Real-world example

// ESM file loading legacy CJS in Node import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const legacy = require('old-cjs-only-package');

Node cannot require() an ESM file — use dynamic import() from CJS instead. Default import from CJS in ESM maps to module.exports; named imports may be synthetic.

Interview: CJS gives a snapshot export; ESM gives live bindings the importer reads at access time.

Static Analysis & Tree-shaking

Definition

Tree-shaking needs statically known import sites. ESM import { foo } from './lib' tells the bundler exactly which binding is used before any code runs.

CJS const lib = require('./lib'); lib.foo() is dynamic — the bundler cannot prove which properties are read without executing or conservatively keeping the whole export object.

// utils.mjs — tree-shakeable export function used() { return 'keep'; } export function unused() { return 'drop'; } // app.mjs import { used } from './utils.mjs'; console.log(used()); // only `used` remains in production bundle

Prerequisites for dead export elimination:

  • Static import — resolvable at build time
  • Accurate sideEffects in package.json
  • No dynamic property access on namespace imports

Real-world example

{ "name": "my-utils", "sideEffects": false }

sideEffects: false tells the bundler unused files can be dropped entirely. Wrong when any file patches prototypes, imports CSS, or runs polyfills on load.

Interview: Tree-shaking needs static ESM imports plus truthful sideEffects metadata — name both before tool names.

Circular Dependencies & Live Bindings

Definition

In CJS cycles, require returns a partial module.exports — whatever was assigned before the cycle re-entered. You often get undefined for not-yet-assigned exports.

In ESM cycles, bindings exist before evaluation but stay in TDZ until the exporting module’s line runs — then they are live: importers see updates when the exporter assigns.

// counter.mjs let n = 1; export { n }; export function bump() { n += 1; } // app.mjs import { n, bump } from './counter.mjs'; console.log(n); // 1 bump(); console.log(n); // 2 — live binding, not a copied primitive

Importers cannot assign imported bindings — read-only view. Refactor cycles with dependency injection or a shared types-only module.

Interview: ESM changes when bindings are visible; it does not make circular dependencies good design.

Native Browser ESM vs Bundler Output

Definition

Browsers load ESM with <script type="module"> — strict MIME, defer by default, and CORS for cross-origin modules. Bare specifiers like import from 'react' fail without import maps or a dev server.

<script type="module"> import { formatPrice } from './utils/format.js'; document.querySelector('#price').textContent = formatPrice(1999); </script> <script type="importmap"> { "imports": { "lodash-es/": "https://cdn.example.com/lodash-es/" } } </script>

Native ESM works for small apps and CDN libraries; production SPAs still bundle for request count, minification, hashed filenames, and legacy browser targets.

import.meta.url resolves file-relative asset paths. import.meta.env is a Vite compile-time injection — not a browser standard.

Interview: Native ESM scales O(requests) with module count; bundling reduces to O(chunks) with content-hashed caching.

Dual-Package Hazard & Migration

Definition

The dual-package hazard happens when a library ships both CJS and ESM entry points and consumers end up with two copies of singleton state — broken instanceof, duplicate React contexts, or split caches.

{ "name": "my-lib", "type": "module", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }

Node’s exports field should point "import" and "require" to builds that share internal state, or publish ESM-only.

Migration: enable "type": "module" incrementally, rename to .cjs/.mjs where needed, replace module.exports with export, swap dynamic require for import(), run dual builds in CI.

TypeScript: moduleResolution: bundler matches Vite/webpack; NodeNext matches Node’s strict ESM/CJS rules — use for publishable libraries.

Real-world example

User-visible symptom: duplicate React contexts — two Reacts in one graph. Always mention this when naming two entry files without a shared singleton.

Staff migration: codemod + CI matrix testing both entry paths; document exports in internal packages as an API contract reviewed in PRs.

Interview: Dual-package hazard breaks singletons — duplicate React is the classic production symptom.

Senior Interview Summary

CJS vs ESM

CJS is runtime require() with snapshot exports. ESM is parse-time linking with live bindings.

Tree-shaking

Static import { x } enables shaking; require() and dynamic property access often retain entire modules.

Circular deps

CJS returns partial exports at first require. ESM links bindings first; TDZ until initializer runs.

Native browser ESM

type="module", relative URLs only, no bare npm specifiers without import maps.

Dual-package hazard

Two physical module instances break singletons — align exports map or publish one format.

Best mental model

CJS → runtime require · snapshot · depth-first ESM → static parse · live bindings · shakeable graph import() → dynamic · Promise · async chunk in bundlers

Practical rules

Libraries for shaking → publish ESM + accurate sideEffects Conditional loading → import() not static import Interop from ESM to CJS → default import usually works Publishable packages → exports field as API contract

What to say in interviews

Lead with load model (runtime vs parse-time), then static analysis (why bundlers prefer ESM), then operational risk (dual-package hazard, migration). Mention __esModule interop only when debugging mixed codebases — it is a convention, not a language feature.

Last updated on