JavaScriptMedium

CommonJS vs ES Modules

01

The Short Answer

CommonJS (CJS) and ES Modules (ESM) are two module systems for organizing JavaScript code into reusable files. CommonJS uses require() and module.exports, runs synchronously, and was designed for Node.js. ES Modules use import/export, support static analysis and tree shaking, and are the official JavaScript standard supported by both browsers and Node.js. ESM is the future — but CommonJS still dominates the Node.js ecosystem due to legacy code.

02

CommonJS (CJS)

CommonJS was created for Node.js in 2009 — before JavaScript had a native module system. It uses require() to load modules synchronously and module.exports to expose values. Modules are loaded at runtime, which means you can use require() inside conditionals, loops, or any expression.

commonjs-example.cjsjavascript
// math.js — exporting
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// or: exports.add = add; exports.multiply = multiply;

// app.js — importing
const { add, multiply } = require('./math');
console.log(add(2, 3)); // 5

// Dynamic/conditional require (works because it's runtime)
if (process.env.NODE_ENV === 'development') {
  const devTools = require('./dev-tools');
  devTools.enableLogging();
}

// require() is synchronous — blocks until file is loaded and executed
const fs = require('fs'); // Loaded immediately, blocking

CommonJS modules are evaluated once and cached — subsequent require() calls for the same module return the cached export object. The synchronous nature works fine for Node.js (reading from disk is fast) but is unsuitable for browsers where network loading is async.

03

ES Modules (ESM)

ES Modules are the official JavaScript module standard (ES2015+). They use import/export syntax, are statically analyzable (imports must be at the top level, not inside conditions), and support named exports, default exports, and re-exports. The static structure enables tree shaking — bundlers can eliminate unused exports at build time.

esm-example.tstypescript
// math.ts — named exports
export function add(a: number, b: number): number {
  return a + b;
}

export function multiply(a: number, b: number): number {
  return a * b;
}

// Default export (one per module)
export default class Calculator {
  // ...
}

// app.ts — importing
import Calculator, { add, multiply } from './math';
import * as math from './math'; // Namespace import

console.log(add(2, 3)); // 5
console.log(math.multiply(4, 5)); // 20

// Re-exporting from another module
export { add, multiply } from './math';
export { default as Calculator } from './math';

// Dynamic import (async, creates code split point)
const { heavyFunction } = await import('./heavy-module');

Static imports must be at the top level — you can't put them inside if blocks or functions. This constraint is what enables tree shaking: the bundler can determine at build time exactly which exports are used and eliminate the rest. Dynamic import() is the escape hatch for runtime-conditional loading.

04

Key Differences

CommonJSES Modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous (browsers) / sync-like (bundlers)
EvaluationRuntime (dynamic)Parse time (static)
Conditional importsYes (require in if blocks)No (use dynamic import() instead)
Tree shakingNot possible (runtime resolution)Yes (static analysis at build time)
Top-level awaitNot supportedSupported
this at top levelmodule.exportsundefined
File extension.js or .cjs.mjs or .js with type:module
Browser supportNeeds bundlerNative (script type="module")
Circular depsPartial exports (whatever was set so far)Live bindings (always current value)
05

Live Bindings vs Value Copies

A subtle but important difference: CommonJS exports are value copies — if the exporting module changes a variable after export, importers see the old value. ES Modules use live bindings — importers always see the current value of the exported variable. This matters for mutable state shared between modules.

live-bindings.tstypescript
// --- CommonJS: value copy ---
// counter.cjs
let count = 0;
module.exports = { count, increment: () => { count += 1; } };

// app.cjs
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 (still!) — got a copy of the value

// --- ES Modules: live binding ---
// counter.mjs
export let count = 0;
export function increment() { count += 1; }

// app.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 — live binding reflects the current value

Live bindings mean ESM exports are like a window into the module's internal state — you always see the latest value. CommonJS exports are a snapshot taken at the time of require(). This distinction matters for circular dependencies and shared mutable state.

06

Using ESM in Node.js

Node.js supports both module systems, but you need to tell it which one a file uses. There are two ways: file extensions (.mjs for ESM, .cjs for CJS) or the type field in package.json.

package.jsonjson
{
  "name": "my-app",
  "type": "module",
  "// ": "All .js files in this package are treated as ESM",
  "// ": "Use .cjs extension for any CommonJS files"
}

Interop between CJS and ESM

ESM can import CJS modules (import cjsModule from './file.cjs'). But CJS cannot use static import for ESM — it must use dynamic import() which returns a Promise. This asymmetry is a common source of frustration when migrating.

07

Why Interviewers Ask This

This question tests your understanding of JavaScript's module ecosystem and build tooling. Interviewers want to see that you know the syntax differences, understand why ESM enables tree shaking (static analysis), can explain live bindings vs value copies, know how to configure Node.js for ESM, and understand the practical implications for bundle size and performance. It shows you understand the toolchain, not just the application code.

Quick Revision Cheat Sheet

CJS syntax: require() / module.exports — synchronous, runtime

ESM syntax: import / export — static, enables tree shaking

Tree shaking: Only ESM — bundler removes unused exports at build time

Live bindings: ESM exports reflect current value; CJS exports are copies

Node.js ESM: Use .mjs extension or "type": "module" in package.json

Dynamic import: import() — async, works in both systems, creates code split