Modules, Linking, and Realms
ES modules are a three-phase graph algorithm with live bindings, not a file-inclusion mechanism.
The module system is where the specification is at its most algorithmic: Module Records, a depth-first linking pass that creates indirect bindings before any code runs, a post-order evaluation pass with explicit cycle handling, and an asynchronous extension for top-level await. Realms are the other half of the isolation story: every global, every intrinsic, and every module map is per realm, which is why identity checks across iframes and `vm` contexts fail in specific, predictable ways.
In this chapter
- Module Records and the three phases
- Live bindings are indirection, not copying
- Cycles, evaluation order, and the hoisting asymmetry
- Top-level await changes evaluation into an asynchronous graph algorithm
- CommonJS is a function call; ESM is a graph. Interop lives in the gap.
- Realms: one set of intrinsics per global
Module Records and the three phases
Each module is represented by a Source Text Module Record holding its parsed code, its [[Environment]] (a Module Environment Record, created during linking), its [[Namespace]], its [[Status]] (new → unlinked → linking → linked → evaluating → evaluating-async → evaluated), and four normalised lists derived from the parse: [[RequestedModules]], [[ImportEntries]], [[LocalExportEntries]], [[IndirectExportEntries]], [[StarExportEntries]]. Processing happens in three strictly separated phases, and each phase completes for the entire graph before the next begins:
- Parse / fetch. The host (via
HostLoadImportedModule) fetches and parses the root and, recursively, every specifier in[[RequestedModules]]. Import and export declarations are syntactically static: specifiers must be string literals, andimport/exportmust appear at the top level. This is what lets the whole graph be discovered before any of it runs, and what lets bundlers tree-shake. - Link.
Link()performs a depth-first traversal (InnerModuleLinking), and for each module runsInitializeEnvironment: it resolves every import to its ultimate exporting module and binding name (ResolveExport, which follows re-export chains and detects ambiguity inexport *conflicts), creates the Module Environment Record with an indirect binding for each import, hoists function declarations, and creates uninitialised bindings forlet/const/class. No module code has executed yet. A link error (import { missing } from './m.js') is aSyntaxErrorthrown before any module in the graph runs. - Evaluate.
Evaluate()performs a depth-first post-order traversal (InnerModuleEvaluation): dependencies run before dependents, each module runs exactly once, and in a cycle the module that was entered first runs last.importbindings therefore point at variables that may still be in TDZ when a cycle is involved.
Live bindings are indirection, not copying
import { count } from './counter.js' does not copy a value. CreateImportBinding(envRec, "count", counterModule, "count") records that the local name count is the binding count in the other module's environment. GetBindingValue("count") on the importing environment forwards to the exporting environment's binding each time it is evaluated; if that binding is still uninitialised, the read throws ReferenceError (a cross-module TDZ). Imports are also immutable from the importer's side (count = 1 is a SyntaxError at parse time when count is an import; a computed write is a TypeError at runtime) while remaining mutable from the exporter's side. Functions are hoisted and initialised during linking, so a cycle can safely call functions from a module that has not yet evaluated, as long as those functions do not touch that module's not-yet-initialised let/const bindings.
// Create two real ES modules at runtime so the sandbox can import them.
const counterSrc = `
export let count = 0;
export function inc() { count++; }
export default "default export";
export const obj = { tag: "obj" };
`;
const counterUrl = URL.createObjectURL(new Blob([counterSrc], { type: "text/javascript" }));
const ns = await import(counterUrl);
console.log("initial count:", ns.count);
ns.inc(); ns.inc();
console.log("after inc() twice, importer sees:", ns.count, "(live binding: no copy was made)");
// The namespace is a Module Namespace exotic object.
console.log("Object.prototype.toString:", Object.prototype.toString.call(ns));
console.log("keys are sorted code-unit order, default included:", Reflect.ownKeys(ns).map(String));
console.log("[[GetPrototypeOf]] is null:", Object.getPrototypeOf(ns) === null, "| isExtensible:", Object.isExtensible(ns));
try { ns.count = 99; } catch (e) { console.log("[[Set]] on a namespace:", e.constructor.name); }
try { Object.defineProperty(ns, "count", { value: 1 }); } catch (e) { console.log("[[DefineOwnProperty]]:", e.constructor.name); }
console.log("descriptor reports the *current* value and writable: true, enumerable: true, configurable: false:", Object.getOwnPropertyDescriptor(ns, "count"));
// Importing the same URL again returns the same module instance (per-realm module map).
const again = await import(counterUrl);
console.log("same namespace object:", again === ns);
URL.revokeObjectURL(counterUrl);If import() is unavailable in this sandbox's Worker, the engine or browser does not support dynamic import in classic workers; run the snippet in a page or in Node.
The namespace object's descriptor claim deserves a pause. [[GetOwnProperty]] on a Module Namespace exotic object reports { [[Writable]]: true, [[Enumerable]]: true, [[Configurable]]: false } with the live value, yet [[Set]] always returns false and [[DefineOwnProperty]] succeeds only when the requested descriptor is compatible with that exact shape. The object is not frozen (Object.isFrozen(ns) is false because writable is reported as true), it is not extensible, and its bindings change underneath you. It is the one built-in that cannot be modelled by the ordinary property descriptor semantics, and Proxy invariants (chapter 10) were written to be compatible with it.
Cycles, evaluation order, and the hoisting asymmetry
// a.js (entry)
import { fromB } from "./b.js";
export function fromA() { return "A"; }
export const aConst = "a is initialised";
console.log("a evaluating; fromB() =", fromB());
// b.js
import { fromA, aConst } from "./a.js";
export function fromB() { return "B"; }
console.log("b evaluating; fromA() =", fromA()); // OK: function declarations are initialised at link time
console.log(aConst); // ReferenceError: aConst is in TDZ (a.js has not run yet)Post-order evaluation means b.js runs first even though a.js is the entry point: a was entered first, its dependency b is evaluated to completion, and only then does a's body run. fromA() works from inside b because function declarations were instantiated during InitializeEnvironment; aConst throws because its binding exists but is uninitialised. CommonJS behaves differently in the same situation: require returns the partially populated exports object, so b would see undefined for aConst rather than throw, and fromA would be missing entirely unless a.js assigned it before its require('./b') line. Both are cycle-tolerant; ESM fails loudly and CJS fails silently.
Top-level await changes evaluation into an asynchronous graph algorithm
With await at module top level, a module's evaluation can suspend, and everything that depends on it must wait. The specification handles this without making every module asynchronous: InnerModuleEvaluation marks a module [[HasTLA]] if it contains top-level await, and any module with an async dependency is also treated as async. Such modules are evaluated by ExecuteAsyncModule, and completion propagates through AsyncModuleExecutionFulfilled to [[AsyncParentModules]], each of which tracks [[PendingAsyncDependencies]] and starts its own body as soon as the count reaches zero. Siblings that do not depend on each other evaluate concurrently (their awaits interleave); dependents still wait for all their dependencies. The consequence for library authors: a top-level await in a widely imported module delays the start of every importer, and it cannot be required from CommonJS (Node's require(esm) support throws ERR_REQUIRE_ASYNC_MODULE for TLA modules).
CommonJS is a function call; ESM is a graph. Interop lives in the gap.
CommonJS has no specification-level existence. require is a synchronous host function that reads a file, wraps its source in (function (exports, require, module, __filename, __dirname) { ... }), evaluates it, and returns module.exports. Exports are a plain object assembled at runtime, so they cannot be statically analysed (module.exports[computed] = ... is legal) and cannot be live (exports.count is a property copy at the time the consumer reads it). Node's ESM loader bridges the two by executing a CJS module and then synthesising a namespace with a default export equal to module.exports plus named exports detected by a static lexer (cjs-module-lexer) over the source. That is why import { named } from 'cjs-pkg' works for exports.named = ... but not for exports produced by loops or Object.assign, and why the same package can appear to have different shapes under different bundlers. The "exports" field with conditions ("import", "require", "default") in package.json exists so packages can ship both formats, at the cost of the dual-package hazard: a process can hold two copies of the same module (one ESM, one CJS) with separate state.
Realms: one set of intrinsics per global
A Realm Record holds [[Intrinsics]] (every built-in object: %Object.prototype%, %Array%, %Promise%, ...), [[GlobalObject]], [[GlobalEnv]], [[TemplateMap]] (chapter 10), and the host's module map. Each iframe, each vm.createContext in Node, and each ShadowRealm (TC39 proposal) is a new realm with its own intrinsics. Functions carry their realm in [[Realm]], and GetFunctionRealm is consulted whenever an algorithm needs a default prototype: OrdinaryCreateFromConstructor falls back to the constructor's realm's intrinsic, not the caller's. So new otherRealm.Array() produces an array whose [[Prototype]] is otherRealm.Array.prototype, and instanceof Array in the current realm returns false. The specification's brand checks (Array.isArray, IsPromise, the [[ErrorData]] slot for Object.prototype.toString) work across realms because they inspect internal slots, not prototypes; instanceof does not. Registered symbols (Symbol.for) are shared across realms of an agent; ordinary symbols and well-known symbols are also shared by specification (the well-known symbols are agent-wide, not per realm).
// Inside a Worker there is only one realm, so we demonstrate the *tests* that are realm-robust
// versus those that are not, on same-realm values; in a page, replace `other` with an iframe's contentWindow.
const other = globalThis;
const arr = new other.Array(1, 2, 3);
const err = new other.TypeError("x");
console.log("robust: Array.isArray:", Array.isArray(arr), "| toString tag:", Object.prototype.toString.call(err));
console.log("fragile: instanceof (breaks across realms):", arr instanceof Array, err instanceof TypeError);
console.log("robust: Symbol.for shared across realms:", Symbol.for("k") === other.Symbol.for("k"));
console.log("robust: well-known symbols are agent-wide:", Symbol.iterator === other.Symbol.iterator);
console.log("structuredClone rebuilds values in the receiving realm:", structuredClone(new Map([[1, 2]])) instanceof Map);Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Modules with static interfaces | David Parnas; Modula-2 (Niklaus Wirth), On the Criteria To Be Used in Decomposing Systems into Modules (1972); Programming in Modula-2 (1982) (1982) | Information hiding through explicit import/export lists that a compiler can check; ES modules' static declarations make the dependency graph analysable before evaluation. |
| Live bindings and cyclic module linking | Dave Herman, Sam Tobin-Hochstadt, Yehuda Katz, Allen Wirfs-Brock (TC39), ES2015 module semantics (Link/Evaluate over Module Records) (2015) | Indirect bindings rather than value copies, and depth-first post-order evaluation with cycle tolerance, designed against CommonJS's partial-exports behaviour. |
| Realms and intrinsics | Netscape (frames as separate globals); Allen Wirfs-Brock (ES2015 Realm Records), ECMA-262 6th edition, §9.3 Realms (2015) | Making the per-global set of intrinsics an explicit specification object so cross-frame behaviour (Array.isArray versus instanceof) could be specified rather than left to browsers. |
| Top-level await as asynchronous graph evaluation | Myles Borins, Guy Bedford, et al. (TC39), Top-level await proposal (ES2022) (2022) | Extends InnerModuleEvaluation with async parents and pending-dependency counts so sibling modules evaluate concurrently. |
Primary sources
- ECMA-262, §16.2.1.5 Source Text Module Records (Link, Evaluate, InitializeEnvironment)
- ECMA-262, §16.2.1.5.3.1 InnerModuleEvaluation and top-level await
- ECMA-262, §10.4.6 Module Namespace Exotic Objects
- ECMA-262, §9.3 Realms
- HTML Living Standard: module map and HostLoadImportedModule
- Node.js docs: Modules — ECMAScript modules (interoperability with CommonJS)
- TC39: ShadowRealm proposal