Modules & Bundlinghigh

ESM vs CommonJS

CommonJS (require/module.exports) is Node's original module system: synchronous, runtime resolution. ESM (import/export) is the TC39 standard: static, async-capable, always strict mode.

Memory anchor

CJS require() is taking a Polaroid photo — you get a snapshot, and the original can change without your photo updating. ESM import is a live security camera feed — you always see the current state in real time.

Expected depth

ESM differences from CJS: (1) static analysis — import statements are parsed at compile time, not executed; (2) live bindings — imported values reflect mutations in the exporting module (unlike CJS snapshots); (3) always strict mode; (4) top-level await support; (5) .mjs extension or 'type: module' in package.json for Node ESM. CJS exports a cached module object — require() is synchronous and caches. Mixing CJS and ESM: you can require() a CJS module from ESM, but you cannot require() an ESM module from CJS (use dynamic import instead).

Deep — senior internals

ESM loading goes through three phases: Construction (parse and resolve all imports recursively, build module graph), Instantiation (allocate bindings, connect live bindings without values), Evaluation (execute module code, fill bindings). This makes circular dependency handling different from CJS: CJS returns the partially-built module.exports at the time of the circular require (a snapshot); ESM has live bindings that can be filled later. Top-level await in ESM blocks the instantiation of dependent modules — modules that depend on an async module wait until it resolves. Dual package hazard: publishing both CJS and ESM versions of a package can lead to two separate module instances if bundlers or Node mix them, breaking singleton patterns.

🎤Interview-ready answer

ESM and CommonJS are fundamentally different: ESM is statically analyzed at parse time (enabling tree shaking), uses live bindings (exports reflect mutations), and runs in strict mode. CJS is dynamic (require is a runtime call), returns snapshots of module.exports, and works synchronously. In modern Node.js, prefer ESM with 'type: module'. The key interview point: ESM's static structure is what enables bundlers to tree-shake dead code.

Common trap

ESM imports are LIVE BINDINGS, not copies. If a module exports let count = 0 and later mutates it, importing modules see the updated value. This is unlike CJS where you get a snapshot of the value at require() time. This live binding behavior is intentional for circular dependency support but can be surprising.

Related concepts