JavaScript Top-Level Await and Dependency Graphs
Top-level await allows developers to use the
await keyword outside of async functions at
the root level of ECMAScript (ES) modules. This article explains what
top-level await is, how it fundamentally alters the
execution order of the JavaScript module dependency graph, and how the
runtime manages asynchronous module evaluation without blocking sibling
dependencies.
What is Top-Level Await?
Introduced in ECMAScript 2022 (ES2022), top-level await
enables ES modules to act as asynchronous entry points. Prior to this
feature, await could only be executed inside functions
marked with the async keyword. To perform asynchronous
operations at startup—such as fetching configuration data, connecting to
databases, or dynamically loading dependencies—developers had to wrap
logic inside Immediately Invoked Async Function Expressions (IIAFEs) or
export promises.
Top-level await eliminates these workarounds by allowing
direct asynchronous evaluation in the module body:
// dbConnection.js
const config = await fetch('/api/config').then(res => res.json());
export const connection = await createDatabaseConnection(config);Module Execution and the Dependency Graph
JavaScript engines process ES modules in three distinct phases: 1. Construction (Parsing): Finding, downloading, and parsing all module files into module records. 2. Instantiation: Allocating memory for module bindings (linking imports and exports). 3. Evaluation: Executing the code to populate memory locations with real values.
In a standard, synchronous module graph, module evaluation follows a depth-first, post-order traversal. The engine traverses to the deepest leaf modules, evaluates them synchronously, and bubbles up to the root module.
How Top-Level Await Alters Execution
When a module uses top-level await, its evaluation
becomes asynchronous, converting the module and any parent importing it
into an asynchronous boundary. This affects graph execution in the
following ways:
1. Paused Module Evaluation
When the engine encounters a top-level await in a
module, execution of that specific module is paused until the awaited
promise resolves. The module returns an unresolved promise to its
importers.
2. Upstream Blocking (Parent Modules)
Any parent module that imports a module with top-level
await cannot begin evaluation until all its imported
dependencies finish evaluating. Consequently, the resolution propagates
upward, delaying the parent module’s execution until the child’s
asynchronous operation completes.
3. Non-Blocking Sibling Branches (Concurrent Execution)
Top-level await does not block the entire application.
Sibling branches in the dependency graph that do not depend on the
awaiting module continue to evaluate normally.
For example, consider the following graph:
Root
/ \
ModuleA ModuleB
|
ModuleC (contains top-level await)
- The engine begins evaluation at
ModuleC. ModuleChits anawaitand pauses evaluation.ModuleAmust wait because it depends directly onModuleC.Rootmust wait because it depends onModuleA.ModuleBevaluates immediately because its sub-graph contains no unresolved dependencies.- Once
ModuleCresolves,ModuleAevaluates, followed finally byRoot.
Common Use Cases
- Dynamic Dependency Loading: Importing polyfills or platform-specific implementations based on runtime conditions.
- Resource Initialization: Initializing databases, authenticating clients, or loading translation files before rendering the application.
- Module Fallbacks: Gracefully falling back to a secondary CDN or local module if a primary resource fails to load.
Considerations and Best Practices
While top-level await provides cleaner initialization
syntax, it introduces potential performance bottlenecks. A slow or
stalled asynchronous operation in a deeply nested leaf module will halt
the evaluation of all ancestor modules. To avoid slow application
startup times, top-level await should be reserved for
essential initializations rather than non-critical background tasks.