"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 nearestpackage.json:"type": "module"means ESM,"type": "commonjs"(or missing) means CJS. - Same rules apply to
.tswhen 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
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.urlinstead 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
requirean ESM file only via dynamicimport(); the syncrequireerrors withERR_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
"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.