Modules
A module graph is built before any of it runs, linked by reference before any value exists, and evaluated once per module in an order the graph decides. This part is the three phases, live bindings and why cycles throw where they do, top-level await and async evaluation, how the host resolves a specifier and why the key is the identity, CommonJS and the Node bridge and what bundlers preserve, and the tools.
Three phases: construct, link, evaluate
"If a.js and b.js both import util.js, does util.js run twice? And in what order does any of this happen?"
Once, and in an order fixed by the graph. ES modules are not executed as encountered; the whole graph is built first, then linked, then evaluated depth-first in post-order so that every module runs after its dependencies and exactly once. The engine (via the host for fetching) owns this process, which is why the ordering is identical in every environment and bundlers must preserve it.
- Resolve each specifier to a key (URL in browsers, path in Node) using the host's algorithm (chapter 4).
- Fetch the source. Browsers fetch in parallel as the graph is discovered; the preload scanner cannot see nested imports, so
<link rel="modulepreload">exists to start them early. - Parse into a Source Text Module Record: the list of requested modules, import entries (local name → module, import name), export entries (local, indirect, star), and the body. Parsing is eager for the module's top level and lazy for functions, as in part 1.
- Recurse until every reachable module is in the module map. Any failure (network, syntax) fails the whole operation; nothing has executed.
- Create each module's environment: a Module Environment Record with every top-level binding declared and in its temporal dead zone (functions are initialised now, because they are hoisted).
- Resolve imports to bindings: for each import entry, call ResolveExport on the target module, which follows re-exports (
export { x } from,export * from) to the module that actually declares the binding. The importing environment gets an indirect binding pointing at that slot. Ambiguous star exports and missing names fail here. - No code runs. Linking is a graph operation. Cycles are fine: every module's bindings exist before any module evaluates.
- Depth-first post-order from the entry: a module's dependencies are evaluated (in the order of its import statements) before its own body. Each module is evaluated once; a status field (unlinked, linking, linked, evaluating, evaluated) prevents repeats and detects cycles.
- The body runs as a function with the module environment as its scope: top-level
let,constandclassinitialise their bindings as they execute; exported values become visible to importers at that moment. - Errors during evaluation are recorded on the module; importers see the same error (a module that failed to evaluate stays failed; re-importing does not retry).
- Top-level await makes the module's evaluation asynchronous; importers wait on its promise (chapter 3).
Live bindings and cycles
An import is not a copy of a value; it is a reference to the exporting module's binding slot. That one design choice makes cycles workable, makes export let observable, and makes "when was this read" the question that decides whether a cyclic import throws.
// a.js
import { b } from './b.js'
export const a = 'A'
export function getA() { return a }
console.log('a sees b =', b) // ReferenceError? no: b is a const in b.js and b.js evaluated FIRST (post-order), so it's fine
// b.js
import { a, getA } from './a.js'
export const b = 'B'
console.log('b sees a =', a) // ReferenceError: a is in its TDZ (a.js has not evaluated yet: b.js ran first)
console.log('b calls getA =', getA()) // ReferenceError inside getA: same reason (a not initialised). the function itself exists (hoisted + linked)
setTimeout(() => console.log(a), 0) // 'A': by then a.js has evaluated. live binding.
// entry: import './a.js' → evaluation order: b.js, then a.js (depth-first post-order: a's dependency b first)
// the rule for cycles: do not read the other module's top-level bindings during your own top-level evaluation. inside functions is fine.- Reads go through the slot every time:
import { count }thencountcompiles to a load from the exporter's environment (LdaModuleVariablein bytecode, with a cell per export), not from a local copy. - Writes are forbidden on the importing side (a TypeError at runtime; most tools catch it statically). Only the exporting module can reassign.
export default exprcreates a hidden binding*default*initialised once; it is live only in the sense that it is a slot, but nothing can reassign it.export default function f() {}is hoisted like any function declaration.- Namespace objects (
import * as ns, or the result ofimport()) are sealed objects whose properties are getters over the bindings: still live, with a property access cost per read. - In optimised code a module variable read is a load from a cell with a stable address; the optimiser treats
constexports as constants once initialised (a "module constant" fast path) andletexports as memory loads.
- Linking succeeds for any cycle: bindings exist in TDZ.
- Evaluation order is still post-order; the module that is reached first in the DFS has its dependencies evaluated first, except the one that is currently evaluating (the cycle back-edge), which is skipped.
- So the module "deeper" in the cycle runs first and sees the other's
let/const/classbindings uninitialised (ReferenceError on read) and its function declarations initialised (callable, but they may read TDZ bindings themselves). - The rule: in a cycle, no module may read the other's top-level values during its own top-level evaluation. Reads inside functions called later are fine; function declarations calling each other are fine. If a cycle needs top-level values from both sides, break it: move the shared values to a third module both import.
- CommonJS cycles behave differently:
requirereturns the partially populatedmodule.exportsobject. No TDZ error, but silently missing properties. ESM's error is the better failure.
Top-level await and async module evaluation
await at the top level of a module makes that module's evaluation a promise. The graph evaluation algorithm was extended (2021) so that importers wait for async dependencies, siblings that do not depend on them proceed, and the whole thing remains deterministic.
- A module with TLA (or that transitively imports one) is marked async at link time (the
HasTLAflag propagates up the graph). - Evaluation returns a promise. Synchronous modules below an async one evaluate as before. When evaluation reaches an async module, its body runs until its first
await(as an async function would), and the modules that depend on it are deferred: they will evaluate when its promise settles. - Independent branches continue. If main imports a (async) and b (sync, not depending on a), b evaluates without waiting for a. Only the transitive importers of a wait.
- Order among async modules is by the post-order DFS index, so completion handling is deterministic: when two async modules settle, their dependents are evaluated in graph order, not in settlement order.
- Rejection: a rejected TLA fails the module and every importer, the same way a thrown error during sync evaluation would.
- Startup latency: everything above an async module waits. A TLA that fetches configuration in a leaf module delays the whole app's evaluation by that fetch. Use it for things the app cannot run without; otherwise export a promise and let consumers await it where they need it.
- Blocking in Node: a CommonJS
require()of an ESM graph containing TLA throwsERR_REQUIRE_ASYNC_MODULE; the synchronous bridge cannot wait. Dynamicimport()works. - Deadlock-shaped cycles: a cycle where both sides have TLA and each awaits something that depends on the other can hang; the spec detects some cases and the rest are design errors.
- Good uses: conditional dependency loading (
const impl = await import(cond ? './a.js' : './b.js')), awaiting a WebAssembly instantiation that the module's exports wrap, a one-time resource initialisation that must precede any use. - Bundlers emulate TLA with generated async wrappers and ordering code; the output is correct but larger. Check that the bundler supports it before relying on it in library code.
node --experimental-print-required-tla (when hitting the require error) prints which module has the TLA. In a browser, Performance shows the gap between a module's fetch finishing and its dependents evaluating. A TLA on the critical path is a serial fetch where a parallel one was possible.Resolution: what the host does with a specifier
The engine hands every specifier string to the host and receives a module record back. The host's resolution algorithm decides which file or URL the string means, and the resolved key is the module's identity: one key, one instance. Most "why are there two copies of this module" problems are two different keys for the same code.
- Relative and absolute specifiers (
./,../,/,file:): resolved as paths. ESM requires the full filename including extension; CommonJS probes extensions andindex.js. - Bare specifiers (
react,lodash-es/debounce): walk up looking fornode_modules/<name>; read itspackage.json. "exports": if present, the only public entry points. Subpath patterns ("./*": "./dist/*.js"), conditional objects keyed by condition names (import,require,node,default, and custom), matched in object order. Anything not listed is unreachable from outside the package."imports"(#internalspecifiers): private aliases within a package, also conditional.- Fallback:
"main", thenindex.js."module"is a bundler convention Node ignores. - Format detection:
.mjsis ESM,.cjsis CommonJS,.jsfollows the nearestpackage.json's"type"(default commonjs; Node 22+ also detects ESM syntax in ambiguous files). - Symlinks are resolved to real paths by default (so a workspace package is one instance);
--preserve-symlinkschanges the key and can duplicate. - Customisation: module customisation hooks (
module.register) for loaders that transpile or redirect; the mechanism TypeScript runners and mocking libraries use.
- Specifiers are URLs, resolved against the importing module's URL. Bare specifiers are errors unless an import map maps them.
- Import maps: a
<script type="importmap">with"imports"(name or prefix → URL) and"scopes"(different mappings for modules under a path prefix). Must appear before any module script; multiple maps are merged in order in recent browsers. - The module map is keyed by the full URL including query string.
./a.js?v=1and./a.js?v=2are two modules. - MIME type must be a JavaScript type or the fetch fails; CORS applies to cross-origin modules (they are fetched with
corsmode, unlike classic scripts).
node --print "require.resolve('lodash-es/debounce')" and node --input-type=module -e "console.log(import.meta.resolve('lodash-es/debounce'))". If they differ, the package's exports map has different require and import conditions, and CJS and ESM consumers in one process get two instances of the package.CommonJS, the bridge, and bundlers
// what a bundler does with the module graph (the same three phases, at build time) // 1. construct: resolve + parse every module from the entries (its own resolution: conditions, aliases, extensions) // 2. link: build the import/export graph; mark used exports (tree shaking relies on: ESM static imports + "sideEffects" hints) // 3. emit: concatenate module bodies in evaluation order into a chunk, renaming bindings to avoid collisions (scope hoisting), // wrap modules it could not hoist (CJS, dynamic patterns) in functions with a tiny runtime require, // split at import() boundaries into chunks, with a manifest mapping chunk ids to files // what survives into the output: evaluation order (must match), live-binding semantics (emulated via shared variables or getters), // module identity (one instance per resolved id, unless the bundler sees two paths) // what is lost: the host's lazy fetching (replaced by chunks), per-module caching (one chunk is one cache entry), source positions (source maps restore them)
requireis a function: resolve, checkModule._cache, read, wrap the source in a function with(exports, require, module, __filename, __dirname), compile it (vm.compileFunction; Node's compile cache can store the result), run it, returnmodule.exports.- Exports are values set during that run; later mutations of the object are visible to holders of the object, not to those who destructured.
- Everything is synchronous and dynamic: conditional requires, computed paths, requires inside functions. Nothing is known statically.
- Cycles return the partial exports object. Bugs are silent.
- ESM importing CJS: the CJS module is loaded (synchronously, by the CJS loader),
module.exportsbecomes the default export, and named exports are synthesised by statically scanning the CJS source forexports.x =patterns (cjs-module-lexer). Dynamic export patterns are missed:import { x } from 'cjs-pkg'can fail whereimport pkg from 'cjs-pkg'; pkg.xworks. - CJS requiring ESM:
require(esm)is supported (Node 22.12+ by default): the ESM graph is linked and evaluated synchronously, returning the namespace object (withdefaultas a property). If the graph contains TLA, it throws. Before that, onlyawait import(). - Dual packages: an exports map with
"import"and"require"conditions pointing at ESM and CJS builds. The hazard: both get loaded in one process (through different consumers), giving two instances with separate state. Mitigate by making one a thin wrapper over the other, or by shipping one format and a stub.
- Keep: evaluation order (they topologically sort), module identity (one instance per resolved id), live bindings (emulated by hoisting modules into one scope and renaming, or by getters when a module is wrapped).
- Change: the unit of fetching (chunks instead of files), the resolution algorithm (their own conditions, aliases, and extension handling;
browserfield support), the module format (output as an IIFE, a single ESM chunk, or a CommonJS file). - Tree shaking depends on ESM's static exports plus a side-effect declaration (
"sideEffects": falseor a list) so that unused modules can be dropped entirely. CommonJS and dynamic patterns defeat it. - Scope hoisting concatenates module bodies into one function scope, which removes per-module function wrappers and lets the engine inline across modules; it is also why a bundled app's top level is one huge eager function (part 1's startup concern).
Reading modules: tools
| Tool | Shows | Use it for |
|---|---|---|
node --trace-exit / a console.log per module top level | Evaluation order, by observation | Confirming the post-order you expected |
import.meta.resolve(spec) (ESM), require.resolve(spec) (CJS) | The resolved key for a specifier under each loader | Dual-package hazards; "which file is this import actually loading" |
node --experimental-print-required-tla | Which module's TLA blocked a require() | The require(esm) failure |
NODE_DEBUG=module,esm node app.js | Resolution steps and loader decisions | Why a bare specifier resolved where it did |
node --print-bytecode on a module: LdaModuleVariable, StaModuleVariable | Module binding access as cell loads | Seeing that imports are references, in bytecode |
| DevTools Sources → Page tree; Network → Type: script with "module" initiator chains | The fetched module graph and who requested each | Serial fetch chains to break with modulepreload |
| Performance panel: "Evaluate Module" and "Link Modules" events | Time in each phase per module | A slow module body on the critical path |
npx madge --circular src/ or the bundler's circular-dependency plugin | Every cycle in the graph | Finding cycles before they become TDZ errors |
| Bundler analysers (webpack-bundle-analyzer, rollup-plugin-visualizer, esbuild --analyze) | What ended up in each chunk and why (the reason chains) | Tree shaking failures; duplicate packages |
npm ls <pkg>, pnpm why <pkg> | Every copy of a package in node_modules and who depends on it | The duplicate-instance diagnosis |