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)
  1. The engine begins evaluation at ModuleC.
  2. ModuleC hits an await and pauses evaluation.
  3. ModuleA must wait because it depends directly on ModuleC.
  4. Root must wait because it depends on ModuleA.
  5. ModuleB evaluates immediately because its sub-graph contains no unresolved dependencies.
  6. Once ModuleC resolves, ModuleA evaluates, followed finally by Root.

Common Use Cases

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.