C.W.K.
Stream
Lesson 01 of 05 · published

CJS vs ESM — Two Module Systems, One Runtime

~14 min · modules, cjs, esm, interop

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Node has two module systems that mostly cooperate. When they don't, that's where the dual-package hazard lives."

Why Two Systems Exist

For its first decade Node only had CommonJS (CJS) — require('lodash') and module.exports = .... CJS was synchronous: require blocks until the file is loaded and evaluated. That worked because Node reads from local disk fast.

Meanwhile the JavaScript spec evolved its own module system — ESM, the import / export syntax that browsers needed (because over-the-network module loading must be asynchronous and statically analyzable). When Node committed to supporting ESM (started ~Node 12, stable in Node 14, default-friendly in Node 16+), it had to keep CJS working too. Every npm package written before 2020 was CJS.

The result: in 2026 Node, both systems live side by side. You can write either. You can mix them — with caveats. Understanding the boundary is the difference between "my import works" and "why does this say require is not defined?"

How Node Decides Which Loader to Use

  • File ends in .mjs → ESM, always.
  • File ends in .cjs → CJS, always.
  • File ends in .js → look at the nearest package.json: "type": "module" means ESM, "type": "commonjs" (or missing) means CJS.
  • Same rules apply to .ts when using --experimental-strip-types: .mts, .cts, or follow the nearest package.json's type field.

The package.json "type" field is the most important decision in any new Node project. Add "type": "module" on day one. ESM is the present and future; default-CJS is a 2010-era leftover.

Differences That Actually Bite You

Syntactic: require/module.exports vs import/export. Semantic: CJS is sync; ESM is async (file loading happens in a separate phase before execution). Practical traps:
  • ESM has top-level await; CJS does not.
  • ESM has import.meta.url instead of __filename/__dirname.
  • ESM imports are static — you can't conditionally import based on runtime state without dynamic import().
  • CJS exports are mutable references; ESM exports are bindings (live, but read-only from the consumer).
  • CJS can require an ESM file only via dynamic import(); the sync require errors with ERR_REQUIRE_ESM (though Node 22+ relaxes this for many CJS-only packages).

The Dual-Package Hazard

Some packages ship both a CJS build and an ESM build (via the exports map in their package.json). If one consumer loads the CJS version and another consumer loads the ESM version of the same package, you end up with two independent copies in memory — different module state, different singletons, different class identities. instanceof can fail across the boundary because the "same" class is actually two different classes.

This is the dual-package hazard. The Node maintainers wrote an entire docs page warning about it. The mitigation: pick one format and stick with it; if you must ship both, make the package stateless (no singletons, no module-level state) or have the CJS build be a thin re-export of the ESM build.

Pippa's Confession

For my first year I treated CJS/ESM as a binary choice: "pick one." Dad pointed out that's not the world — half the npm registry is CJS-only, and you can't escape it. The real skill is knowing when you can mix and when you can't. New code: ESM ("type": "module", .mjs for one-offs). Old packages you depend on: CJS, lived with via dynamic import() or named imports from CJS (which Node helpfully synthesizes). The 2026 Node experience is mostly painless if you start ESM and only fall back to CJS when forced.

Code

Pure ESM — the modern Node way·javascript
// ESM (./greet.mjs)
export function greet(name) {
  return `hi, ${name}`;
}
// default export
export default { greet };

// Consumer (./app.mjs, or any file in a `"type": "module"` package)
import { greet } from './greet.mjs';
import greetDefault from './greet.mjs';
import * as everything from './greet.mjs';

console.log(greet('Dad'));
console.log(import.meta.url);   // file:///.../app.mjs
// __filename / __dirname don't exist in ESM
CJS — what most legacy packages still ship·javascript
// CJS (./greet.cjs)
function greet(name) {
  return `hi, ${name}`;
}
module.exports = { greet };

// Consumer (./app.cjs)
const { greet } = require('./greet.cjs');
console.log(greet('Dad'));
console.log(__filename, __dirname);  // these exist in CJS

// Loading ESM from CJS — must be dynamic
const pretty = await import('chalk');  // OK in Node 22+ even from CJS
// or, for older Node: top of CJS file can't await — use top-level Promise:
// import('chalk').then(({ default: chalk }) => { ... });

External links

Exercise

Create a tiny project: mkdir m-test && cd m-test && npm init -y. Edit package.json to add "type": "module". Write lib.mjs exporting a function. Write main.mjs importing it. Run node main.mjs — works. Now rename lib.mjs to lib.cjs (don't change its content yet). Run again — fails. Why? Fix it without going back to .mjs. (Hint: convert the contents to CJS.) You've felt both directions of the boundary.
Hint
The .cjs extension forces CJS regardless of package.json. CJS uses module.exports, not export. Inside a CJS file: module.exports = { greet }. The ESM importer's import { greet } from './lib.cjs' will work because Node synthesizes named exports from CJS's module.exports object.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.