Modules (CommonJS vs ESM)

Difficulty: Intermediate

Question

Compare CommonJS and ES Modules. What are the key differences and when do you use each?

Answer

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'.

Code examples

CommonJS vs ES Modules Syntax

// === 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 Imports and Code Splitting

// 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.

Live Bindings vs Value Copies

// 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.

Key points

Concepts covered

ESM, CommonJS, import, export, Dynamic Import, Module Pattern