JavaScript Top-Level Await and Module Execution Order

Top-level await allows developers to use the await keyword at the root level of ECMAScript modules (ESM) without wrapping the code inside an async function. This feature simplifies asynchronous resource initialization, dynamic imports, and module configuration. However, introducing asynchronous operations at the module level fundamentally changes how JavaScript evaluates dependency trees, shifting the execution order from a strictly synchronous, depth-first traversal to a promise-driven, non-blocking asynchronous graph resolution.

Understanding Top-Level Await

Prior to ECMAScript 2022 (ES2022), the await keyword was strictly restricted to the body of functions marked with async. When a module required asynchronous setup—such as connecting to a database, fetching remote configuration, or conditionally loading dependencies—developers had to rely on Immediately Invoked Async Function Expressions (IIAFEs) or export pending promises:

// Legacy Approach (IIAFE)
let config;
export const init = (async () => {
  const response = await fetch('/api/config');
  config = await response.json();
})();
export { config };

This pattern caused race conditions if consuming modules attempted to use exported values before the initialization promise resolved.

Top-level await resolves this limitation by turning the entire module into an asynchronous module record. You can await promises directly in the module’s top-level scope:

// Modern Approach with Top-Level Await
const response = await fetch('/api/config');
export const config = await response.json();

How Module Loading Works Without Top-Level Await

Standard ECMAScript module loading operates in three distinct phases:

  1. Parsing (Construction): The engine fetches and parses all files recursively into Module Records, building the dependency graph.
  2. Instantiation: Memory locations are allocated for all module exports and imports, linking them together (bindings are created).
  3. Evaluation: The engine executes the module code.

In a traditional ESM graph without top-level await, evaluation is synchronous and deterministic. The engine executes modules in a post-order traversal (bottom-up, depth-first). A parent module never executes until all of its child dependencies have finished executing their code synchronously from start to finish.

How Top-Level Await Changes Execution Order

When a module uses top-level await, it becomes an asynchronous module. This modifies the evaluation phase in several key ways:

1. Blocking Downstream (Parent) Modules

An imported module that contains a top-level await will pause the evaluation of any module that imports it. The importing parent module will not begin executing its own top-level code until all awaited promises within its dependencies have successfully resolved.

Unlike traditional synchronous blocking, this pause does not freeze the main thread. It yields execution back to the JavaScript event loop, allowing unrelated scripts and browser tasks to continue running.

2. Concurrent Sibling Evaluation

Top-level await allows sibling dependencies in the module graph to execute concurrently. Consider the following dependency structure where main.js imports both moduleA.js and moduleB.js:

       main.js
      /       \
 moduleA.js   moduleB.js

If moduleA.js contains a top-level await, the execution proceeds as follows: * moduleA.js starts evaluating and hits the await keyword. * Execution of moduleA.js pauses, and its pending state is registered. * The engine does not wait idly; it immediately begins evaluating moduleB.js (if moduleB.js does not depend on moduleA.js). * main.js waits until both moduleA.js and moduleB.js have fully resolved their execution before it evaluates.

3. Tree-Wide Promise Coordination

Every module in an ES module graph that imports an asynchronous module effectively becomes an asynchronous module itself. The JavaScript runtime tracks module evaluation as a graph of promises:

4. Deterministic Graph Resolution

Despite being asynchronous, the execution order remains deterministic based on the dependency graph. A parent module is guaranteed to run after its dependencies resolve. If multiple child modules have top-level await, they initialize concurrently, but the consumer module will strictly wait for the entire subtree to complete.

Failure Handling and Rejections

If a top-level await promise rejects within a module: * The module evaluation fails, and the rejection propagates up the module tree. * Dependent parent modules will not execute. * The error surfaces as an unhandled promise rejection unless caught using standard try...catch blocks within the module itself:

let data;
try {
  const response = await fetch('https://api.example.com/data');
  data = await response.json();
} catch (error) {
  console.error('Failed to load dynamic data, using fallback:', error);
  data = { fallback: true };
}
export { data };

Top-level await transforms JavaScript module loading from a rigid, synchronous execution chain into a coordinated asynchronous dependency graph, enabling cleaner asynchronous initialization while preserving deterministic execution order across the module tree.