How Top-Level Await Changes Module Evaluation
Top-level await allows developers to use the await
keyword outside of async functions at the root scope of an
ECMAScript Module (ESM). This capability transforms module evaluation
from a strictly synchronous, post-order traversal into an asynchronous
execution graph, enabling modules to pause their own completion and the
execution of their dependents while asynchronous tasks resolve.
Traditional Module Evaluation
Before top-level await, the lifecycle of ECMAScript modules followed three distinct, synchronous phases once files were fetched: construction (parsing), instantiation (wiring imports and exports in memory), and evaluation (executing the code).
Evaluation occurred in a deterministic, depth-first, post-order sequence. The JavaScript runtime traversed to the deepest leaf modules first, executed their top-level code synchronously from top to bottom, and then walked back up the tree to parent modules. The entire dependency graph evaluated in a single, uninterrupted turn of the event loop. If a module needed asynchronous data, it had to export a promise or an initialization function, forcing consumer modules to manage that asynchronous state manually.
The Mechanism of Top-Level Await
When an engine encounters a top-level await within a
module, that module is internally marked as an asynchronous module. The
evaluation of an asynchronous module returns a promise to the runtime,
pausing execution at the point of the await expression and
yielding control back to the event loop.
Crucially, top-level await only pauses the execution of:
- The module containing the
awaitexpression. - Ancestor modules that directly or indirectly import the paused module.
It does not block the evaluation of sibling branches within the module graph that do not depend on the paused module.
How the Evaluation Sequence Changes
With top-level await, module execution shifts from a rigid synchronous post-order traversal to a coordinated concurrent flow:
- Topological Order Preservation: The runtime starts
evaluating the dependency graph in the standard post-order traversal.
Code prior to the first
awaitexecutes synchronously in normal order. - Suspension and Branch Isolation: When a module
pauses at a top-level
await, the runtime suspends that module's execution context. However, it continues evaluating other independent sibling modules in the graph. Sibling modules that do not import the suspended module run to completion without waiting for the asynchronous operation to settle. - Dependent Blocking: Any parent module that imports an asynchronous module is held in a pending evaluation state. Its top-level code will not begin executing until all imported asynchronous modules have fully resolved their top-level promises.
- Resolution and Resumption: Once the awaited promise settles (either resolves or rejects), the engine schedules the resumption of the suspended module as a microtask. Upon resolution, dependent modules proceed with their evaluation in the order established by the dependency graph.
Error Handling and Rejections
Top-level await alters error propagation across the module tree. If an awaited promise rejects, the module execution fails, and the rejection propagates directly to any importing parent modules, triggering an unhandled rejection if not captured. This behavior prevents dependent modules from executing in an inconsistent or partially initialized state, ensuring that imports are never accessed before their asynchronous initialization has successfully completed.