Modules
Date: 2026-09-28
JavaScript has two module systems. ESM links a static graph before running anything and exports live bindings; CommonJS runs files on demand and exports a plain object. Almost every “why won’t this import” error is the seam between them.
A module is a file with its own scope that shares bindings only through explicit exports. JavaScript has two module systems. ESM (ECMAScript modules) is the language standard, using import/export. CommonJS (CJS) is Node’s original system, using require()/module.exports.
The two, side by side
| ESM | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| When imports resolve | Before any code runs — parse the whole graph, link, then evaluate | When require() executes, mid-file |
| What an import gives you | A live binding to the exporter’s variable | A copy of whatever module.exports held at that moment |
| Can be conditional | Only via import(), which returns a promise | Yes — require is just a function |
Top-level await | Yes | No |
| Strict mode | Always | Opt-in |
Top-level this | undefined | module.exports |
| File paths | Full specifier, extension included, in Node and browsers | Extension and index.js inferred |
| Current file location | import.meta.url (import.meta.dirname in Node) | __dirname, __filename |
Static structure is ESM’s defining property. Because imports are declarations, not function calls, tools know the whole graph without running it — which is what makes tree-shaking possible — Module Bundling.
Live bindings — the load-bearing difference
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 — the import is a view of counter's variable
// the CommonJS equivalent
// counter.cjs: let count = 0; module.exports = { count, increment };
// main.cjs: const { count, increment } = require('./counter.cjs');
// increment(); console.log(count); // 0 — a copy taken at require timeImporters can’t assign to an import (count = 5 throws) — only the exporting module can change it.
Evaluated once, cached by resolved path
A module’s top level runs once per resolved file. Every importer gets the same instance, which is why a module-level let cache = new Map() behaves as an app-wide singleton.
The dual-package hazard follows directly. A package ships both an ESM and a CJS build. Your code imports it, a dependency requires it — two different files, so two instances. The consequences:
- two singletons, each with half the state
instanceoffailing across the boundary- two copies of the code in the bundle
Node’s seam
- Which system a
.jsfile uses is set by the nearestpackage.json’s"type":"module"means ESM; absent or"commonjs"means CJS..mjsand.cjsforce it per file - Packages map entry points with the
exportsfield, choosing a file per condition (import,require,browser,types). A package withexportsalso hides every file it doesn’t list - ESM importing CJS works. The default import is
module.exports. Named imports work only when Node can detect them statically, which is best-effort - CJS requiring ESM was impossible for years (
await import()was the only route).require(esm)is now unflagged in Node v20.19, v22.12 and v23+, and marked stable from v25.4. It returns the module namespace object, with the default under.default. It throwsERR_REQUIRE_ASYNC_MODULEif the module graph uses top-levelawait
In the browser
<script type="module">is deferred by default, fetched with CORS (cross-origin resource sharing), and runs once however often it’s included- Import maps let bare specifiers (
import 'lodash') resolve without a bundler - Unbundled, a deep graph is a waterfall of requests discovered one level at a time. That’s why production still bundles, and why
modulepreloadexists — Resource Hints
Where it goes wrong
- Circular imports. In ESM, a module that reads an import before the exporter has finished evaluating gets a TDZ (temporal dead zone)
ReferenceError. In CJS it silently gets a half-filled exports object. Both depend on import order, so they appear and vanish as files are reshuffled — Scope and Hoisting - “Cannot use import statement outside a module” — ESM syntax in a file Node treats as CJS. Fix the
"type"or the extension, not the code - Default-export interop. Transpiled code marks CJS output with
__esModule, and tools disagree on whetherimport x from 'cjs-pkg'meansmodule.exportsormodule.exports.default. The same import behaves differently under the bundler, Node and the test runner — Transpilation - Top-level side effects. Code that runs on import (registering globals, patching prototypes) defeats tree-shaking and makes import order matter. The
sideEffectsfield inpackage.jsontells bundlers which files are safe to drop