JavaScript Module Execution Semantics
JavaScript modules are instantiated and evaluated using dependency-aware rules that determine when module code runs and in what order. Understanding these rules explains import timing, cycles, live bindings, and why top-level effects can occur before a dependent module executes.
A module is first parsed and linked with its dependencies before its executable statements run. During instantiation, imported and exported bindings are established, but the module body has not necessarily executed yet.
Evaluation then executes module code according to the dependency graph. A dependency must be evaluated before the module that depends on it can complete its own evaluation, while unrelated branches can follow their own evaluation paths.
Imports are statically resolved and are not ordinary runtime property reads. An imported name refers to a binding maintained by the exporting module, so changes to an exported binding can be observed by importers; this is commonly described as a live binding.
Cycles are allowed. The module system creates bindings for the complete dependency graph before evaluation, but accessing a lexical binding before its initialization can cause a `ReferenceError`. This is one reason circular dependencies can behave differently from simple linear imports.
Top-level `await` adds asynchronous evaluation to the module graph. A module containing it can suspend its evaluation, and dependent modules may have to wait for that evaluation to complete before continuing.
Key points
- Instantiation establishes imports and exports before module bodies execute.
- Evaluation executes module bodies according to dependency relationships.
- Static imports create live bindings rather than copied values.
- Circular dependencies are supported but can expose uninitialized lexical bindings.
- Top-level `await` can suspend module evaluation and delay dependents.
Interview questions
What effect does top-level `await` have on module evaluation?
Top-level `await` allows a module's evaluation to suspend while an asynchronous operation completes. Its dependents can be prevented from completing evaluation until the relevant asynchronous evaluation finishes. This makes module initialization asynchronous without requiring an explicit wrapper function. It can therefore affect startup timing and dependency ordering.
Why can a module's top-level side effect happen before code in the importing module?
A static import establishes a dependency before the importing module's body is evaluated. The dependency must be evaluated as part of processing that module graph. Consequently, top-level statements in the dependency can execute before the importer's first statement. This is why importing a module can trigger initialization code even when the importer never explicitly calls a function from it.
What happens when JavaScript evaluates an imported module?
The module system first resolves and links the module's dependencies and bindings. It then evaluates dependencies before completing evaluation of the importing module. The imported module's top-level statements can therefore run before statements in the importer. The imported names refer to the export bindings rather than independent copied variables.
Are ES module imports copies of exported values?
No. An import refers to a binding owned by the exporting module. If that exported binding can be reassigned, an importer observes its current value rather than receiving an independent copy. Importers cannot directly reassign an imported binding.
How do circular module dependencies work?
Circular dependencies are represented as cycles in the module dependency graph and are not automatically rejected. The module system establishes the relevant bindings before evaluation, allowing the cycle to be linked. During evaluation, however, a module can attempt to access a lexical binding before the exporting module has initialized it. Such an access can produce a `ReferenceError`, so correct behavior depends on when the bindings are used.