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.
| System | Syntax | When exports resolve | Bundler analysis |
|---|---|---|---|
| CommonJS | module.exports / require() | Runtime, depth-first | Dynamic — hard to shake |
| ESM | export / import | Parse-time, live bindings | Static — 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)); // 5CJS: require() → execute module → snapshot module.exports
ESM: parse imports → link live bindings → evaluate in dependency orderIn 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 bundlePrerequisites for dead export elimination:
- Static
import— resolvable at build time - Accurate
sideEffectsinpackage.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
sideEffectsmetadata — 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 primitiveImporters 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
exportsmap 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 bundlersPractical 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 contractWhat 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.