How it works
ECMA-262 models a module as a Source Text Module Record, a kind of Cyclic Module Record, with a [[Status]] that moves new, unlinked, linking, linked, evaluating, (evaluating-async), evaluated. Three methods drive it, and the host (browser, Node.js) decides how to fetch.
Loading. LoadRequestedModules walks [[RequestedModules]] and calls the host hook HostLoadImportedModule for every specifier not yet loaded. The host fetches, parses, and answers asynchronously. A module that is already in the visited list isn't followed again, which is what ends a cycle. When the count of pending requests reaches zero, every record moves to unlinked. HTML's "fetch the descendants of and link a module script" is exactly this: LoadRequestedModules, then Link().
Linking. Link runs InnerModuleLinking, a depth-first traversal that tracks strongly connected components so a cycle links as a unit. For each module, InitializeEnvironment does the following:
- Check that every indirect export resolves.
- Create the Module Environment Record.
- For each import, call
ResolveExport on the exporter, then CreateImportBinding. This is the live link: a binding that stores the target module and name, not a value. A star import instead binds the namespace object.
- Declare the module's own
var (initialized to undefined), let/const/class (uninitialized), and function declarations (instantiated immediately).
GetBindingValue on an indirect binding goes to the target module's environment, and throws ReferenceError if that environment doesn't exist yet or the target binding is uninitialized.
Evaluation. Evaluate runs InnerModuleEvaluation, again depth-first: mark the module evaluating, recurse into each requested module in order, then run the module's own body (ExecuteModule). A module already evaluating is skipped, so a cycle does not recurse forever. Because each module runs once, after its dependencies, the order is a post-order walk. If root imports c and then a, a imports b, and b imports c, the bodies run c, b, a, root (verified in Node.js 24).
A failed evaluation is stored in the record: later imports of that module rethrow the same error rather than re-running it.
Edge cases
Link-time failures stop everything before output. Given bad.mjs containing console.log('bad body'); import { nope } from './counter.mjs';, Node.js prints only SyntaxError: The requested module './counter.mjs' does not provide an export named 'nope', and bad body never appears.
The namespace object is a module namespace exotic object. Its [[Get]] reads the live binding, [[Set]] returns false (strict-mode assignment throws), [[GetPrototypeOf]] returns null, and it is non-extensible. Properties report { writable: true, enumerable: true, configurable: false }, sorted by code unit order, plus Symbol.toStringTag of "Module":
import * as ns from './counter.mjs';
console.log(Object.getPrototypeOf(ns), Object.isExtensible(ns), ns[Symbol.toStringTag]);
console.log(Object.getOwnPropertyDescriptor(ns, 'count'));
// → null false Module
// → { value: 1, writable: true, enumerable: true, configurable: false }
The value: 1 is the state after the example's single inc() call. Writes fail even though the descriptor says writable: true; the descriptor is a reflection of the live value, not a promise that [[Set]] works.
export * from skips default, and a name provided by two star exports is ambiguous. import * as hub just omits it, but import { x } is a link-time SyntaxError ("contains conflicting star exports"). An explicit local export wins over a star export.
Real-world scenario
In a browser, <script type="module"> runs the construct and link phases without blocking the parser and evaluates after the document has been parsed, like defer. The async attribute makes it evaluate as soon as the graph is ready. Fetching, the module map, import maps, and CORS belong to the browser runtime topic. Bundlers read the same static structure to build the graph at build time and remove unused exports.
Trade-offs
Static structure buys early errors, whole-graph analysis (tree shaking, parallel fetch), and live bindings that make cycles workable. The cost is that import can't depend on runtime values: no import inside if, no computed specifier. import() is the escape hatch, at the price of an asynchronous result.