Overview
JavaScript has two module systems in widespread use: CommonJS (Node.js original) and ES Modules (the standard). They look similar but have fundamentally different semantics: CJS loads at runtime, ESM is statically analyzed at parse time. This difference is why tree shaking requires ESM and why mixing the two in the same project causes confusing errors.
Architecture
CJS vs ESM EVALUATION MODEL
════════════════════════════════════════════════════════
CommonJS (require — synchronous, runtime):
┌─────────────────────────────────────────────────┐
│ const counter = require('./counter'); │
│ // module is executed NOW, synchronously │
│ // exports object is returned as a snapshot │
│ counter.increment(); │
│ console.log(counter.value); // sees snapshot │
└─────────────────────────────────────────────────┘
ESM (import — static, compile-time):
┌─────────────────────────────────────────────────┐
│ import { value, increment } from './counter'; │
│ // TWO phases: │
│ // 1. Parse: link all imports (before exec) │
│ // 2. Evaluate: run module code │
│ increment(); │
│ console.log(value); // LIVE binding — sees │
│ // current module value! │
└─────────────────────────────────────────────────┘
LIVE BINDING DEMO
════════════════════════════════════════════════════════
// counter.mjs (ESM)
export let count = 0;
export const increment = () => count++;
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 ← live binding sees updated value
// counter.cjs (CJS)
let count = 0;
module.exports = { count, increment: () => count++ };
// main.cjs
const mod = require('./counter.cjs');
console.log(mod.count); // 0
mod.increment();
console.log(mod.count); // 0 ← snapshot! count is a copy, not a live binding
TREE SHAKING: WHY ESM IS REQUIRED
════════════════════════════════════════════════════════
ESM: bundler reads static import/export graph at build time
→ can determine which exports are never imported
→ removes dead code (tree shaking)
CJS: require() can be called anywhere, with any string
const lib = require(condition ? 'a' : 'b'); // dynamic!
→ bundler cannot statically determine what's needed
→ must include entire library
Result: lodash (CJS) bundles 70KB. lodash-es (ESM) tree-shakes to < 1KB.
MODULE RESOLUTION ORDER (Node.js)
════════════════════════════════════════════════════════
require('express'):
1. Check cache (already loaded?)
2. Core modules (fs, path, http, ...)
3. node_modules/express/package.json → "main" (CJS) or "exports" (ESM)
4. node_modules/express/index.js
Technical Implementation
ESM Live Binding vs CJS Snapshot
// --- ESM version ---
// counter.mjs
export let count = 0;
export function increment() { count++; }
export function reset() { count = 0; }
// consumer.mjs
import { count, increment, reset } from './counter.mjs';
console.log(count); // 0 — live binding to counter.mjs's `count`
increment();
increment();
console.log(count); // 2 — live: sees current value in the module
reset();
console.log(count); // 0
// --- CJS version (snapshot behavior) ---
// counter.cjs
let count = 0;
function increment() { count++; }
module.exports = { count, increment }; // count exported as value 0
// consumer.cjs
const counter = require('./counter.cjs');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // STILL 0 — the exported value is a copy
// counter.count is a separate binding, not a live reference to the module's count
Dynamic Import for Code Splitting
// Dynamic import(): returns Promise<module>
// Bundler (Vite/Webpack) creates a separate chunk for each dynamic import
// Before: entire chart library in initial bundle (200KB)
import Chart from 'chart.js'; // always included
// After: chart library loaded only when user opens chart view
async function showChart(data: number[]) {
const { Chart } = await import('chart.js'); // separate 200KB chunk
const canvas = document.getElementById('chart') as HTMLCanvasElement;
new Chart(canvas, { type: 'bar', data: { datasets: [{ data }] } });
}
// React lazy loading (same mechanism)
const HeavyEditor = React.lazy(() => import('./HeavyEditor'));
function App() {
return (
<React.Suspense fallback={<Spinner />}>
<HeavyEditor /> {/* chunk loaded only when rendered */}
</React.Suspense>
);
}
// Conditional imports for environment-specific code
async function loadAnalytics() {
if (process.env.NODE_ENV === 'production') {
const { init } = await import('./analytics');
init();
}
// dev builds: analytics chunk never loaded
}
Dual CJS/ESM Package.json Configuration
{
"name": "my-library",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
// tsup build config (produces both CJS and ESM from one TypeScript source)
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true, // generate .d.ts files
splitting: false,
sourcemap: true,
clean: true,
outExtension({ format }) {
return { js: format === 'esm' ? '.mjs' : '.cjs' };
},
});
Interview Preparation
Q: What is the difference between ESM live bindings and CJS module.exports?
When you export let count from an ESM module and import it elsewhere, the importer gets a live binding — a read-only reference into the exporting module's binding. If the exporter updates count, all importers see the new value immediately. With CJS module.exports = { count }, you're exporting the current value of count as a property of the exports object. The importer gets a copy of the value at require-time. If the exporter later increments count, the importer still has the old value. This is why CJS-style "incremental counter" modules work differently than their ESM equivalents.
Q: How does tree shaking work and why does it require ESM?
Tree shaking is dead code elimination at build time. Bundlers (Rollup, Webpack, Vite) build a dependency graph starting from your entry point, tracking which exports are imported by which consumers. Any export that is never imported is excluded from the bundle. This requires static analysis — the bundler must know at build time what is imported from where. ESM import statements are static: they must be at the top level with literal string specifiers, so the bundler can parse them without executing code. CJS require() can be dynamic (runtime conditions, variable paths), so the bundler cannot safely exclude any exports without potentially breaking runtime behavior.
Q: What happens with circular dependencies in CommonJS vs ESM?
In CJS, circular dependencies produce partially-evaluated modules. If A requires B and B requires A, when B's require(A) runs, A hasn't finished evaluating yet — B gets A's partially-filled exports object (whatever A exported before requiring B). This leads to undefined surprises. In ESM, circular dependencies are handled more gracefully due to the two-phase model: the linking phase resolves all import/export bindings before any code runs. Live bindings mean B can reference A's exports and they'll have their final values by the time B actually uses them (as long as usage happens after evaluation, not at module top-level). Still best to avoid circularity.
Q: How would you publish a package that supports both CJS and ESM consumers?
Use the exports field in package.json with conditions: "import" for ESM consumers and "require" for CJS consumers. Build two output files from the same TypeScript source using a tool like tsup or Rollup (one .mjs ESM bundle, one .cjs CJS bundle). Keep "main" pointing to the CJS file for older Node.js versions that don't understand exports. Generate TypeScript declaration files (.d.ts) once and share them. Be careful: avoid using __dirname and __filename in ESM (use import.meta.url + fileURLToPath), and avoid top-level await in CJS output.
Learning Resources
- Node.js Docs — ESM
- MDN — JavaScript Modules
- Rollup — Tree Shaking
- How to Create a Dual Package — Node.js official guide