CommonJS vs ESM: Module Resolution in Node
CommonJS loads synchronously and ESM loads as an async graph — that single difference drives every interop trap, from default-export shape to the dual-package hazard.
Node ships two module systems that look interchangeable and are not. CommonJS resolves and executes synchronously; ECMAScript Modules resolve a dependency graph asynchronously before any code runs. Almost every interop trap you hit — default-export shape, missing named exports, the dual-package hazard, why require() historically refused an ESM file — falls out of that one difference.
1. require: synchronous, imperative, CommonJS
A CommonJS module is a function the loader runs top to bottom. require() is a blocking call: it resolves the specifier, reads the file off disk, executes it, caches the resulting module.exports object, and returns it — all before the next line runs. Because it is just a function call, you can invoke it conditionally, inside a branch, or lazily on first use.
// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
module.exports.PI = 3.14159;
// app.cjs
const { add, PI } = require('./math.cjs'); // blocks until math.cjs has run
console.log(__dirname); // absolute dir of THIS file
console.log(__filename);
const fs = require('node:fs'); // core modules, same call
The cache is keyed by the fully resolved absolute path, so a second require('./math.cjs') returns the exact same object without re-executing the file. That caching is what makes CommonJS singletons work — and it is also half of the dual-package hazard you will meet below.
2. import: static, hoisted, asynchronous graph
An ES module is not a function the runtime executes line by line on first reference. The loader first parses every module to discover its import/export statements, links the whole graph (binding each imported name to a live slot in the exporting module), and only then evaluates modules bottom-up. Bindings are live, read-only views: if the exporter reassigns an exported let, the importer sees the new value.
// math.mjs
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// app.mjs
import { add, PI } from './math.mjs'; // statically resolved, hoisted
console.log(import.meta.url); // file:// URL of THIS module
console.log(import.meta.dirname); // Node 20.11+/21.2+ replacement for __dirname
Because import statements are static and hoisted, they cannot be placed inside an if block or a function. For conditional or lazy loading you use dynamic import(), which returns a Promise and works in both CJS and ESM. Crucially, __dirname and __filename are not defined in an ES module; use import.meta.dirname/import.meta.filename, or fileURLToPath(import.meta.url) on older runtimes.
3. How Node decides which loader runs
Node does not guess from file contents. The format is determined by, in order: the file extension (.cjs is always CommonJS, .mjs is always ESM), and for a bare .js file, the nearest parent package.json. If that manifest has "type": "module", .js is treated as ESM; otherwise (or with "type": "commonjs" or no field) it is CommonJS. There is no per-file pragma — the extension or the closest package.json wins.
4. Interop: the asymmetry that bites
The two directions are not symmetric.
ESM importing CommonJS works well. Node wraps the CJS module so that module.exports becomes the default export. Named imports are also supported when Node's static analyzer (the cjs-module-lexer) can statically detect the assigned property names — but it is a heuristic on a value computed at runtime, so dynamically built exports may not be detected. The always-safe form is the default import:
// consuming a CommonJS package from an .mjs file
import pkg from 'some-cjs-lib'; // pkg === module.exports, always works
const { add } = pkg; // destructure off the default
import { add } from 'some-cjs-lib'; // works ONLY if the lexer detected `add`
CommonJS importing ESM is the historically hard direction. For years a plain require() of an ESM file threw ERR_REQUIRE_ESM, because require() is synchronous and an ESM graph could contain top-level await, which is inherently asynchronous. As of Node 20.19.0+ and 22.12.0+, require(esm) is unflagged and stable: require() can load an ES module and return its namespace object synchronously — but only if that module's graph contains no top-level await. If it does, require() throws ERR_REQUIRE_ASYNC_MODULE, and you must fall back to dynamic import():
// older Node, or any ESM that uses top-level await:
async function load() {
const mod = await import('./feature.mjs'); // returns a Promise
mod.run();
}
// Node 20.19+/22.12+, ESM with NO top-level await:
const mod = require('./feature.mjs'); // works, synchronous namespace
One more detail: require(esm) returns the module namespace. A default export lives on mod.default, not on mod directly — the shapes do not match CJS's "the export is module.exports" model, so destructure deliberately.
5. Bridging from inside ESM
If you need CommonJS-only affordances (require.resolve, a synchronous require, or loading a JSON file the old way) from within an ES module, build a require bound to the current module:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const config = require('./config.json'); // synchronous, CJS-style
const native = require('./addon.node'); // native addons via require
6. The dual-package hazard
A package that ships both a CJS build and an ESM build via the "exports" field's "require" and "import" conditions can be loaded twice in one process — once through each entry. The CJS copy is cached by resolved path; the ESM copy lives in the separate ESM module map. You then hold two distinct module instances:
// package.json
{
"name": "shape",
"exports": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
If one consumer reaches the package through require() and another through import, any module-level state is duplicated. The classic symptom: instanceof returns false for an object created by one copy and checked against the constructor from the other, and properties patched onto one singleton are invisible to the other. Mitigations include shipping a single ESM source and exposing a thin CJS wrapper that re-exports it, or keeping all stateful logic in one internal CJS module that both builds wrap.
7. A correct interop checklist
- Use
.cjs/.mjswhen you want the format to be unambiguous regardless ofpackage.json. - From ESM consuming CJS, prefer
import x from 'pkg'(the default) over named imports unless you control the package's static export shape. - From CJS consuming ESM,
require()works on modern Node only without top-level await; otherwise useawait import(). - In ESM, replace
__dirnamewithimport.meta.dirnameand reach forcreateRequire(import.meta.url)when you genuinely need CJS resolution.
Takeaways
- The split is sync vs async graph.
require()resolves, executes, and caches in one blocking step; ESM parses, links live bindings, then evaluates the whole graph. - Format comes from extension or the nearest
package.json"type", never from inspecting the file body. - ESM importing CJS is easy (
module.exportsbecomesdefault); named imports rely on a best-effort static lexer. - CJS importing ESM is now possible synchronously on Node 20.19+/22.12+, but only when the ESM graph has no top-level
await; otherwise use dynamicimport(). - The dual-package hazard is two live copies of one package — broken
instanceofand split state — caused by exposing separaterequire/importbuilds with their own module-level state.