Difficulty: Intermediate
Compare CommonJS and ES Modules. What are the key differences and when do you use each?
JavaScript has two module systems: CommonJS (CJS) used primarily in Node.js, and ES Modules (ESM) which is the standard for both browsers and modern Node.js.
CommonJS uses require() for importing and module.exports for exporting. It is synchronous, evaluated at runtime, and supports dynamic require paths. It was designed for server-side use.
ES Modules use import/export syntax. They are asynchronous, statically analyzed at parse time (enabling tree-shaking), and have strict mode enabled by default. ESM supports named exports, default exports, and dynamic import() for code splitting.
Key differences: ESM is statically analyzable (imports must be at the top level), CJS is fully dynamic. ESM exports are live bindings (reflect changes), CJS exports are copied values. ESM uses 'import.meta' for metadata, CJS uses '__filename' and '__dirname'.
// === CommonJS (Node.js) ===
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// OR: exports.add = add;
// app.js
const { add, subtract } = require('./math');
console.log(add(2, 3)); // 5
const math = require('./math');
console.log(math.subtract(5, 2)); // 3
// === ES Modules ===
// math.mjs
export const multiply = (a, b) => a * b;
export default function divide(a, b) { return a / b; }
// app.mjs
import divide, { multiply } from './math.mjs';
console.log(multiply(2, 3)); // 6
console.log(divide(10, 2)); // 5
// Rename imports
import { multiply as mul } from './math.mjs';
CJS uses require/module.exports, ESM uses import/export. ESM allows both named and default exports. CJS destructuring happens at runtime, ESM bindings are resolved at parse time.
// Dynamic import() - works in both CJS and ESM
// Returns a Promise (enables lazy loading)
async function loadModule(moduleName) {
try {
const module = await import(`./modules/${moduleName}.js`);
return module;
} catch (err) {
console.log(`Failed to load ${moduleName}:`, err.message);
}
}
// Practical: Route-based code splitting in React
// const Dashboard = React.lazy(() => import('./Dashboard'));
// Conditional module loading
async function getFormatter(locale) {
if (locale === 'en') {
const { format } = await import('./formatters/english.js');
return format;
} else {
const { format } = await import('./formatters/default.js');
return format;
}
}
// Top-level await (ESM only)
// const config = await import('./config.json', { assert: { type: 'json' } });
Dynamic import() is the standard way to do code splitting. It returns a Promise with the module namespace. React.lazy uses this for component-level splitting.
// CJS: exports are COPIED values
// counter-cjs.js
let count = 0;
function increment() { count++; }
function getCount() { return count; }
module.exports = { count, increment, getCount };
// app-cjs.js
const counter = require('./counter-cjs');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 (still! it is a copy)
console.log(counter.getCount()); // 1 (function reads live variable)
// ESM: exports are LIVE bindings
// counter-esm.mjs
export let count = 0;
export function increment() { count++; }
// app-esm.mjs
import { count, increment } from './counter-esm.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (live binding reflects change!)
// count = 5; // TypeError: read-only binding
CJS copies the value at require time. ESM creates a live reference that always reflects the current value. However, ESM imports are read-only from the consumer side.
ESM, CommonJS, import, export, Dynamic Import, Module Pattern