open atlas
↑ Back to track
Node.js, zero to senior NODE · 01 · 01

CommonJS vs ESM

CommonJS resolves require() synchronously at runtime with value-copy exports; ESM is a static, hoisted, async graph with live read-only bindings and top-level await. Node picks one per file, and the interop seam is where teams bleed time.

NODE Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

Think of a kitchen with two appliances that do the same job — a gas stove and an induction stove — but take incompatible cookware. Put a cast-iron pan on the gas one and it works; move it to the induction one and nothing heats, because each appliance expects pots built for it. Node has exactly two such “appliances” for loading your code into the program — modules (the files you split your code into) running inside the runtime (the engine that actually executes them) — and most of a day lost to Node setup is just a file built for one being fed to the other. Here is what that looks like. You add "type": "module" to package.json to use top-level await, and the next node server.js throws ReferenceError: require is not defined. You change the offending require() to import, and now a dependency explodes with Error [ERR_REQUIRE_ESM]. Then __dirname is not defined shows up where it never used to. None of these are bugs in your code — they are the friction of two module systems that resolve at different times, copy values differently, and disagree about how a file even names its imports.

Why you need this as a developer: every Node app, test runner, and library you publish is built from modules, and the day you mix the two systems — or install a package that picked the other one — these errors land on you. Knowing which system a file is, and why, turns an afternoon of guesswork into a one-line fix.

CommonJS: synchronous, runtime-resolved, value-copied

CommonJS (CJS) is Node’s original module system. You pull a module in with require(), a plain function that runs while your code executes: it reads the target file from disk, evaluates it, and returns whatever that file assigned to module.exports. Because it is an ordinary function call, you can require() conditionally, inside an if, or build the path at runtime — resolution is dynamic and happens line by line.

// math.cjs
function add(a, b) { return a + b; }
let calls = 0;
module.exports = { add, get calls() { return calls; }, bump() { calls++; } };

// app.cjs
const math = require("./math.cjs");   // synchronous: blocks until math.cjs is evaluated
console.log(math.add(2, 3));          // 5
const { add } = math;                 // value copy — `add` is a snapshot of the binding

Two properties matter. First, require() is synchronous and blocking: the calling line does not continue until the required file has fully run, which is why CJS startup is a depth-first walk of the dependency tree. Second, what you get back is a value copy at the moment of require. If you destructure const { add } = require(...), you captured the reference that existed then; if the module later reassigns module.exports.add, your add does not change. CJS also hands every module two path globals for free — __dirname and __filename — because resolution is local and runtime.

ESM: static, hoisted, asynchronous, with live bindings

ECMAScript Modules (ESM) are the standard. You declare dependencies with import/export statements that are static: they must sit at the top level (not inside an if or a function), so the engine can find every import before any code runs. That up-front analysis is the whole point — it builds the full module graph, hoists imports, and lets bundlers see exactly which exports are used so they can drop the rest (tree-shaking). Loading the graph is asynchronous, which is what unlocks top-level await.

// math.mjs
let calls = 0;
export function add(a, b) { return a + b; }
export function bump() { calls++; }
export function getCalls() { return calls; }

// app.mjs
import { add, bump, getCalls } from "./math.mjs";   // hoisted, resolved before run
import { readFile } from "node:fs/promises";

const config = await readFile("config.json", "utf8"); // top-level await — no async wrapper
console.log(add(2, 3));   // 5
bump();
console.log(getCalls());  // 1 — live binding reflects the update

The exports you import are live bindings, not copies: import { add } is a read-only view onto the exporting module’s variable. If that module mutates the value behind the name, your import sees the new value — and you may never reassign an imported name yourself (add = ... is a TypeError). There is no __dirname; instead you derive paths from import.meta.url (e.g. new URL("./data.json", import.meta.url)).

PropertyCommonJSESM
Syntaxrequire() / module.exportsimport / export
Load timingSynchronous, blockingAsynchronous graph
ResolutionDynamic, at runtimeStatic, before execution
Import semanticsValue copy at require timeLive, read-only binding
Path globals__dirname, __filenameimport.meta.url
Top-level awaitNoYes
Tree-shakeableNo (dynamic shape)Yes (static shape)
Why this works

Why does “static” buy tree-shaking? Because import { add } from "./math.mjs" names exactly which binding you use, the bundler can prove bump is never referenced and delete it from the output — before running a line. CJS exports are an ordinary object built at runtime (module.exports.add = ...); a tool cannot know which keys survive without executing the program, so it must keep them all. Static structure is not a style preference — it is what makes the dependency graph analysable.

How Node decides which system a file is

Node does not guess from the contents. It classifies each file as CJS or ESM by an explicit set of signals, in priority order:

  • Extension wins outright. .mjs is always ESM; .cjs is always CJS. These override everything else and are the unambiguous escape hatch.
  • "type" in the nearest package.json. For a plain .js file, Node walks up to the closest package.json: "type": "module" makes .js files ESM, "type": "commonjs" (or no field) makes them CJS. Flipping that one field reinterprets every .js file in the package — which is exactly why require is not defined appears the moment you add it.
  • The "exports" field controls what import "pkg" and require("pkg") resolve to, and via conditional exports can serve a different file to each — the dual-package pattern.
{
  "name": "mylib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./package.json": "./package.json"
  }
}

The "exports" map is more than dual publishing: it is your package’s public API boundary. Anything not listed in exports is unreachable from outside — consumers can no longer deep-import mylib/src/internal.js, even if the file exists. That encapsulation is a senior reason to define exports deliberately rather than letting the whole directory leak.

The interop seam — where the time bleeds

The two systems do not compose symmetrically, and this asymmetry is the real pain of the migration era:

  • ESM can import CJS. import express from "express" works; the CJS module.exports object becomes the ESM default export. Named imports from a CJS module are best-effort: Node statically analyses the CJS source to guess the named exports, but anything assigned dynamically (module.exports[name] = ...) is invisible, so import { Router } from "express" may fail where import express from "express"; const { Router } = express; works.
  • CJS cannot synchronously require() an ESM module — historically it threw ERR_REQUIRE_ESM, because loading ESM is async and require is sync. The portable fix is dynamic import(), which returns a promise and works from either system: const mod = await import("./esm-only.mjs"). (Recent Node versions can require() a synchronous ESM graph, but dynamic import() is the rule that always holds.)
// CJS file needing an ESM-only dependency
async function main() {
  const { default: chalk } = await import("chalk"); // chalk is ESM-only now
  console.log(chalk.green("works from CJS via dynamic import()"));
}
main();
Quiz

A CommonJS file must use a dependency that ships as ESM-only. require('the-pkg') throws ERR_REQUIRE_ESM. What is the portable fix?

Quiz

A module increments an internal counter and exposes it. A consumer reads the counter after the increment. When does the consumer see the new value?

Pick the best fit

You are starting a new Node library in 2026 that others will install. Which module strategy do you publish?

Recall before you leave
  1. 01
    Why are ESM imports tree-shakeable while CommonJS exports are not, and what does 'static' have to do with it?
  2. 02
    Walk through the CJS↔ESM interop rules and how Node decides which system a file is.
Recap

CommonJS and ESM are two module systems that differ in timing, semantics, and structure. CommonJS uses require()/module.exports: resolution is dynamic and synchronous, the call blocks until the target file is evaluated, destructured imports are value copies frozen at require time, and every module gets __dirname/__filename for free. ESM uses import/export statements that are static and hoisted, so the engine builds the whole graph before execution — which enables tree-shaking, an asynchronous graph, top-level await, and live read-only bindings that track the exporter’s current value; paths come from import.meta.url instead of __dirname. Node decides which system a file is in priority order: the .mjs/.cjs extension overrides everything, then the nearest package.json “type” field governs .js files, then the exports field (with conditional import/require) can serve different files to each and define the package’s public API boundary. The interop seam is asymmetric: ESM can import a CJS module (its module.exports becomes default; named imports are best-effort), but CJS cannot synchronously require ESM — reach for dynamic import(), which returns a promise and works from either side. For new code prefer ESM, and treat the exports field as a deliberate API surface rather than an afterthought.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.