Part 11 · 6 chapters · ~40 min

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.

75

Three phases: construct, link, evaluate

the question

"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.

construction (host-driven)
  1. Resolve each specifier to a key (URL in browsers, path in Node) using the host's algorithm (chapter 4).
  2. 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.
  3. 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.
  4. Recurse until every reachable module is in the module map. Any failure (network, syntax) fails the whole operation; nothing has executed.
linking (engine)
  1. 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).
  2. 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.
  3. No code runs. Linking is a graph operation. Cycles are fine: every module's bindings exist before any module evaluates.
evaluation (engine)
  1. 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.
  2. The body runs as a function with the module environment as its scope: top-level let, const and class initialise their bindings as they execute; exported values become visible to importers at that moment.
  3. 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).
  4. Top-level await makes the module's evaluation asynchronous; importers wait on its promise (chapter 3).
run it
Four files, a console.log at the top of each, an entry that imports two of them which both import the fourth. The fourth logs once, first; the order of the two middle ones follows the entry's import statement order. Add a top-level await to one of them and watch the other proceed without waiting.
THE THREE PHASES
construct, link, evaluate: a graph of four modules
swipe the figure sideways, or tap expand for full screen
1/6
construct
Entry main.js imports a.js and b.js; both import util.js. Construction: the host resolves each specifier to a URL or path, fetches, parses to a Module Record (imports, exports, body), and recurses. All four are fetched (in parallel, in a browser) and parsed before anything else happens. A syntax error anywhere fails the whole graph here.
76

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.

code
// 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.
live bindings
  1. Reads go through the slot every time: import { count } then count compiles to a load from the exporter's environment (LdaModuleVariable in bytecode, with a cell per export), not from a local copy.
  2. Writes are forbidden on the importing side (a TypeError at runtime; most tools catch it statically). Only the exporting module can reassign.
  3. export default expr creates 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.
  4. Namespace objects (import * as ns, or the result of import()) are sealed objects whose properties are getters over the bindings: still live, with a property access cost per read.
  5. In optimised code a module variable read is a load from a cell with a stable address; the optimiser treats const exports as constants once initialised (a "module constant" fast path) and let exports as memory loads.
cycles, precisely
  1. Linking succeeds for any cycle: bindings exist in TDZ.
  2. 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.
  3. So the module "deeper" in the cycle runs first and sees the other's let/const/class bindings uninitialised (ReferenceError on read) and its function declarations initialised (callable, but they may read TDZ bindings themselves).
  4. 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.
  5. CommonJS cycles behave differently: require returns the partially populated module.exports object. No TDZ error, but silently missing properties. ESM's error is the better failure.
run it
The two files above, imported from an entry. Then swap the entry to import b.js first and watch the error move to a.js: same cycle, opposite order, because the DFS now reaches b first.
77

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.

the mechanics
  1. A module with TLA (or that transitively imports one) is marked async at link time (the HasTLA flag propagates up the graph).
  2. 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.
  3. 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.
  4. 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.
  5. Rejection: a rejected TLA fails the module and every importer, the same way a thrown error during sync evaluation would.
costs and uses
  1. 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.
  2. Blocking in Node: a CommonJS require() of an ESM graph containing TLA throws ERR_REQUIRE_ASYNC_MODULE; the synchronous bridge cannot wait. Dynamic import() works.
  3. 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.
  4. 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.
  5. 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.
the lens
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.
78

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.

Node
  1. Relative and absolute specifiers (./, ../, /, file:): resolved as paths. ESM requires the full filename including extension; CommonJS probes extensions and index.js.
  2. Bare specifiers (react, lodash-es/debounce): walk up looking for node_modules/<name>; read its package.json.
  3. "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.
  4. "imports" (#internal specifiers): private aliases within a package, also conditional.
  5. Fallback: "main", then index.js. "module" is a bundler convention Node ignores.
  6. Format detection: .mjs is ESM, .cjs is CommonJS, .js follows the nearest package.json's "type" (default commonjs; Node 22+ also detects ESM syntax in ambiguous files).
  7. Symlinks are resolved to real paths by default (so a workspace package is one instance); --preserve-symlinks changes the key and can duplicate.
  8. Customisation: module customisation hooks (module.register) for loaders that transpile or redirect; the mechanism TypeScript runners and mocking libraries use.
browsers
  1. Specifiers are URLs, resolved against the importing module's URL. Bare specifiers are errors unless an import map maps them.
  2. 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.
  3. The module map is keyed by the full URL including query string. ./a.js?v=1 and ./a.js?v=2 are two modules.
  4. MIME type must be a JavaScript type or the fetch fails; CORS applies to cross-origin modules (they are fetched with cors mode, unlike classic scripts).
run it
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.
RESOLVING A SPECIFIER
what the host does with "lodash-es/debounce"
swipe the figure sideways, or tap expand for full screen
1/6
bare specifier
import debounce from "lodash-es/debounce": a bare specifier (not starting with ./, ../, / or a URL scheme). In a browser without an import map this is a TypeError: bare specifiers are not URLs. In Node, the package resolution algorithm starts.
79

CommonJS, the bridge, and bundlers

code
// 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)
CommonJS in one paragraph
  1. require is a function: resolve, check Module._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, return module.exports.
  2. Exports are values set during that run; later mutations of the object are visible to holders of the object, not to those who destructured.
  3. Everything is synchronous and dynamic: conditional requires, computed paths, requires inside functions. Nothing is known statically.
  4. Cycles return the partial exports object. Bugs are silent.
the bridge in Node
  1. ESM importing CJS: the CJS module is loaded (synchronously, by the CJS loader), module.exports becomes the default export, and named exports are synthesised by statically scanning the CJS source for exports.x = patterns (cjs-module-lexer). Dynamic export patterns are missed: import { x } from 'cjs-pkg' can fail where import pkg from 'cjs-pkg'; pkg.x works.
  2. CJS requiring ESM: require(esm) is supported (Node 22.12+ by default): the ESM graph is linked and evaluated synchronously, returning the namespace object (with default as a property). If the graph contains TLA, it throws. Before that, only await import().
  3. 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.
what bundlers keep and change
  1. 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).
  2. Change: the unit of fetching (chunks instead of files), the resolution algorithm (their own conditions, aliases, and extension handling; browser field support), the module format (output as an IIFE, a single ESM chunk, or a CommonJS file).
  3. Tree shaking depends on ESM's static exports plus a side-effect declaration ("sideEffects": false or a list) so that unused modules can be dropped entirely. CommonJS and dynamic patterns defeat it.
  4. 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).
COMMONJS VERSUS ESM
what require does that import does not
swipe the figure sideways, or tap expand for full screen
1/6
require()
require("./util"): a synchronous function call at the point it appears. Node resolves the id (file, directory, package exports map), checks the cache (Module._cache keyed by the resolved path), reads the file, wraps it: (function (exports, require, module, __filename, __dirname) { ...source... }), compiles and runs it, and returns module.exports.
80

Reading modules: tools

ToolShowsUse it for
node --trace-exit / a console.log per module top levelEvaluation order, by observationConfirming the post-order you expected
import.meta.resolve(spec) (ESM), require.resolve(spec) (CJS)The resolved key for a specifier under each loaderDual-package hazards; "which file is this import actually loading"
node --experimental-print-required-tlaWhich module's TLA blocked a require()The require(esm) failure
NODE_DEBUG=module,esm node app.jsResolution steps and loader decisionsWhy a bare specifier resolved where it did
node --print-bytecode on a module: LdaModuleVariable, StaModuleVariableModule binding access as cell loadsSeeing that imports are references, in bytecode
DevTools Sources → Page tree; Network → Type: script with "module" initiator chainsThe fetched module graph and who requested eachSerial fetch chains to break with modulepreload
Performance panel: "Evaluate Module" and "Link Modules" eventsTime in each phase per moduleA slow module body on the critical path
npx madge --circular src/ or the bundler's circular-dependency pluginEvery cycle in the graphFinding 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 itThe duplicate-instance diagnosis
the pointer
Part 12 is the collections: Map and Set as ordered hash tables, the weak variants and what they are for, and typed arrays as the one representation that never changes under you. Module-level Maps are the most common long-lived state in an application, and part 5's leak shapes start there.