ES Modules vs CommonJS — how imports actually resolve

The two module systems aren't just different syntax for the same idea — CommonJS resolves and runs imports synchronously at require-time, while ES Modules are statically analyzed before any code runs at all, and that difference explains real, practical incompatibilities between them.

Intermediate

4 min read

CommonJS (require/module.exports): synchronous, resolved at runtime

// math.js
function add(a, b) { return a + b; }
module.exports = { add };
 
// app.js
const { add } = require("./math"); // require() runs SYNCHRONOUSLY, right at this line, at RUNTIME
console.log(add(2, 3));

CommonJS (Node.js's original module system) resolves and executes a require() call synchronously, exactly at the point in the code where it appears — the required file is read, parsed, and run right then, and require() returns whatever that file assigned to module.exports. Because this happens at actual runtime, require() can be called conditionally (inside an if, inside a function) — something the next section shows ES Modules structurally cannot do.

ES Modules (import/export): statically analyzed before any code runs

// math.js
export function add(a, b) { return a + b; }
 
// app.js
import { add } from "./math.js"; // hoisted, resolved at PARSE time, before any code in this file runs
console.log(add(2, 3));

ES Modules (the standard, browser-native, and now Node-supported system) are fundamentally different: every import statement is found and resolved through static analysis — before the module's own code actually executes — which is why import statements must appear at the top level of a file, never conditionally inside an if or a function, unlike require(). This static structure is also what enables tree-shaking: a bundler can determine exactly which exports are actually used, purely by analyzing the import/export graph without running any code, and exclude the unused parts from the final bundle — something CommonJS's runtime-resolved require() calls make much harder to do reliably.

Named exports vs default exports: not just style, a genuine difference

// Named exports — the exported NAME is fixed, importers must use it (or explicitly rename it)
export function add(a, b) { return a + b; }
import { add } from "./math.js";
import { add as sum } from "./math.js"; // explicit rename, opt-in
 
// Default export — exactly ONE per module, importer chooses ANY name for it
export default function add(a, b) { return a + b; }
import whateverNameIWant from "./math.js"; // no relation to how it was exported

A named export's name is part of its actual identity — importing it requires using that exact name (or an explicit as rename) — while a default export carries no name at all as far as the importer is concerned, so two different files importing the same default export can legally give it two completely different local names, which is both a flexibility and a real source of inconsistency across a codebase (the same imported thing called different names in different files, with no tooling to catch the mismatch). This is part of why some style guides prefer named exports for anything beyond a module's single obvious primary export — the name is enforced, not just conventional.

The real, practical incompatibility: mixing the two isn't always seamless

// A CommonJS package being imported from an ES Module file usually works...
import someCjsPackage from "some-old-package"; // Node/bundlers provide compatibility shims for this
 
// ...but a named import from a CommonJS package sometimes doesn't resolve correctly,
// because CommonJS's module.exports has no concept of "named exports" the way ESM does —
// a real, practical source of confusing import errors when mixing ecosystems

Because the two systems have genuinely different resolution models (runtime vs static), Node.js and modern bundlers provide interoperability shims to let ES Modules import CommonJS packages and vice versa — but the shim is an approximation, not a perfect translation, since CommonJS's module.exports object has no real equivalent to ESM's statically-analyzable named exports. This is the actual, mechanical reason behind a genuinely common real-world frustration: an import that works from one module system but produces a confusing "not a function" or "undefined" error from the other, for a package that seems like it should just work.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the fundamental difference between how CommonJS's `require()` and ES Modules' `import` resolve?

2. Why does ES Modules' static structure enable tree-shaking in a way CommonJS makes difficult?

3. What's the real, practical difference between a named export and a default export?