Web Platform · JavaScript Runtime

Module Linking, Live Bindings, and the Order of Evaluation

ECMAScript modules resolve and instantiate the whole graph before running a single statement, which is what makes imports live and cycles behave unlike CommonJS.

A CommonJS require is a function call: it runs the target file, then hands back the value of module.exports at that instant. An ES import is not a call and does not produce a value. It is a declaration that a binding exists elsewhere, and the engine resolves all such declarations across the entire graph before any module body runs. That single difference — resolve everything, then evaluate — produces live bindings, a well-defined order, and the peculiar behaviour of circular imports.

The model has three phases, and they are strictly ordered. Construction fetches and parses each module, producing a module record and recording its requested modules. Instantiation resolves every import to a specific export of a specific module and allocates the bindings; no code executes. Evaluation runs the module bodies in a depth-first order determined by the graph and at most once per module. A failure in phase one or two is a resolution error; a failure in phase three is a runtime error, and both are cached on the module record.

Construction stops at the parse

Parsing a module is not the same as parsing a script. The grammar is stricter: with is a syntax error, an assignment to an undeclared name throws a ReferenceError instead of creating a global property, and await is a reserved word throughout module code rather than only inside async functions. Every module is strict mode by definition, which means top-level this is undefined rather than the global object, and function declarations do not leak to a global scope.

The parser records the module’s import entries and its export entries in the module record. Because the specifier is a literal known at parse time — dynamic import() is a separate mechanism, covered in dynamic import and the module map — the graph can be traversed without executing anything. This is the property that makes bundlers able to drop unused exports: an export that no module in the graph requests is known to be unrequested before evaluation.

A module’s imports are also hoisted. Placing an import statement after other statements in the source changes nothing; by the time the body runs, all imports are already bound. The declaration cannot appear inside a block, a conditional, or a function body, because the binding must exist before evaluation and the engine does not evaluate conditions during linking.

Instantiation wires bindings, not values

When the graph is instantiated, each import entry is resolved against the exporting module’s export entries. The importer receives an indirect binding — a reference to the exporter’s local binding slot in its environment record — not a snapshot of its current value. Reading the imported name in the importer reads the exporter’s binding at that moment; assigning to it from the importer is a TypeError in strict mode, because the binding is read-only from outside.

Live bindings have two consequences that trip up code ported from CommonJS. First, a module can export a let and reassign it, and importers observe the new value:

// state.js
export let count = 0;
export function increment() { count += 1; }

// view.js
import { count, increment } from './state.js';
increment();
console.log(count);   // 1 — the binding is live, not a copy

Second, the exported name need not be a local name. export { x as y } creates an export entry pointing at the local x; export default expr desugars to a binding with a synthetic name (*default*), initialized at the point the statement appears. That explains why export default function () {} is hoisted but export default someExpression is not: the former is a declaration, the latter an expression evaluated in place.

Re-exporting preserves indirection through however many hops: export { count } from './state.js' in a barrel module does not copy the value, it forwards the binding. Three layers of barrel files therefore cost nothing at runtime and still expose one binding. With export *, names flow through as well, except for default, which is never included in a star export, and except for names exported by two different star sources, which become ambiguous and are excluded rather than resolved by precedence.

Evaluation order and the cycle

Evaluation begins at the entry module and proceeds depth-first over its dependencies in the order their import declarations appear, evaluating a dependency before the module that imports it. The rule is “evaluate the requested modules first, then the body”, with an [[Status]] field preventing double evaluation: a module already evaluated is skipped. For a diamond dependency — two modules both importing a third — the third evaluates once, before either of the two.

A cycle is legal. Consider a.js importing from b.js which imports from a.js. Depth-first evaluation from a descends into b first; b requests a, which is already being evaluated, so the request is skipped and b’s body runs. At that point a’s body has not run. If b reads a const or let declared in a at module top level, the binding exists but is uninitialized, and the read throws a ReferenceError — the temporal dead zone applies across modules exactly as it does within one. If b calls a function declared in a, that works, because function declarations are hoisted and initialized during instantiation.

// a.js
import { bValue } from './b.js';
export function aFn() { return 'a'; }
export const aValue = 'a-value';
console.log('a body', bValue);     // runs second

// b.js
import { aFn } from './a.js';
export const bValue = aFn();       // runs first; aFn is already initialized
console.log('b body');

The order is deterministic, so a cycle that works will keep working, but it constrains the design: within a cycle, only hoisted declarations are usable by the module that evaluates first. CommonJS, by contrast, returns a partially populated exports object and leaves the consumer to cope with whatever happened to be assigned; the ES module model surfaces the problem earlier, at the read, as a TDZ error.

Namespace objects are frozen views

import * as ns yields a module namespace object. It is an exotic object with a null prototype whose own properties are the module’s exports, sorted, with each property a live view of the corresponding binding. Assigning to a namespace property throws in strict code and is silently ignored in sloppy code; adding or deleting one behaves the same way; Object.keys returns the sorted export names. Because the object is a view rather than a copy, a namespace obtained before an export is initialized still reads correctly once the exporting module’s body has run.

The sorting is by code unit order over the exported names, and it is observable through Object.getOwnPropertyNames, Object.keys, and for...in. A namespace object is not itself iterable. Two modules that export a, b, c produce namespaces that enumerate identically regardless of the order the exports appear in the source.

In the document, modules are deferred

An HTML module script has defer semantics implicitly. The parser does not block on it; the browser fetches the graph, and evaluation happens after the document has been parsed but before DOMContentLoaded. Inline module scripts behave the same way. Execution order among several module scripts follows document order, with the caveat that a module shared by two entry scripts evaluates once, in the position where it was first needed.

Errors are per graph. A module that throws during evaluation marks its record as failed; every other module that imports it, directly or transitively, fails with the same error and does not run. There is no partial recovery at the module level, which is why initialization work that can fail belongs inside a function that can be retried rather than at module scope. The error is also cached: a later import of the same specifier reports the original failure instead of retrying evaluation.

Top-level await changes the shape of these guarantees. A module whose body awaits a promise completes its evaluation asynchronously, so its importers must wait for it, and the whole graph becomes asynchronous at that point. The specification propagates this through [[AsyncEvaluation]] and an evaluation error list; the module map caches the in-flight evaluation so that two importers do not start the work twice.

References

  1. ECMAScript Language Specification — Modules — Ecma International
  2. HTML Living Standard — Module scripts — WHATWG
  3. ES modules: A cartoon deep-dive — Lin Clark, Mozilla Hacks