skip to content

What does adding a top-level `await` to an ES module do to the modules that import it, and to unrelated modules elsewhere in the same graph?

level: middleimportance: must knowfreq 58%

answer

  1. the module becomes asynchronous
  2. evaluation now yields a promise
  3. importers wait, unrelated branches do not
  4. asynchrony propagates up the import edges
  5. a never-settling await silently hangs dependents

basics

~20 s

The module becomes an asynchronous module: its evaluation now completes with a promise. Every module that imports it, directly or transitively, has its own body deferred until that promise settles. Modules that do not depend on it still evaluate normally.

solid answer

~40 s

A module containing top-level `await` is an *async module*: instead of running its body to completion in one synchronous step, its evaluation produces a promise that settles when the body finishes. The module system then holds back every dependent — anything that imports it directly or transitively — until that promise fulfils, because the importer's body could otherwise read bindings that have not been assigned yet. It does **not** freeze the whole graph: independent branches with no path to the async module keep evaluating, and the async module's own dependencies still run before it, in the usual order. The visible cost is that the wait is inherited: one slow `await` in a leaf module delays every module above it, and if the awaited promise never settles, those importers never run at all.

code

javascript · 6 lines
javascript
// b.mjs — an async module: its evaluation completes with a promise
console.log('b: start');
export const value = await new Promise((resolve) =>
  setTimeout(() => resolve(42), 100)
);
console.log('b: done');

go deeper

for a junior

Know the headline: a module using top-level await finishes loading later, and anything importing it starts only after that. Be able to say the imported value is guaranteed to be assigned by then.

for a middle

Explain the mechanism — evaluation returns a promise, dependents are deferred until it settles, and the asynchrony propagates transitively up the import edges while unrelated branches keep evaluating.

for a senior

Show you have paid for this in production: inherited startup latency across every consumer of a shared module, silent stalls when an awaited promise never settles, and the discipline of confining top-level await to the entry point.

for a principal

Own the API-design consequence: publishing a module with top-level await makes every downstream consumer's initialisation asynchronous and unusable from synchronous loaders. Be able to argue when that contagion is acceptable versus exposing an explicit async initialiser.

## Ordinary module evaluation An ES module graph is processed in three phases: the host **loads** and parses each file, the engine **links** it (allocating the bindings that `import`/`export` refer to), and then it **evaluates** module bodies. Without top-level await, evaluation is a synchronous depth-first walk: each module's dependencies run to completion first, then its own body runs to completion, and only then does control return to whatever imported it. By the time an importer's first statement runs, every binding it imported already holds its final value. ## What top-level await changes A module whose body contains a top-level `await` cannot run to completion in one go — it has to suspend. The specification models this by making such a module an **async module**: its evaluation step returns a promise rather than finishing immediately. ```js // b.mjs — an async module export const value = await Promise.resolve(42); ``` The module system now has an obligation to the importers of `b.mjs`. If `a.mjs` starts running while `b.mjs` is still suspended, `value` has not been assigned yet, so `a.mjs` would observe a hole. To prevent that, the evaluation of every dependent is deferred until the async module's promise settles: ```js // a.mjs import { value } from './b.mjs'; console.log(value); // 42 — guaranteed, never undefined ``` This is transitive. If `a.mjs` imports `b.mjs` and `c.mjs` imports `a.mjs`, then `c.mjs` also waits, even though `c.mjs` never mentions the async module. The asynchrony propagates *up* the dependency edges: an async module makes all of its dependents asynchronous too. ## What does not wait A common misreading is that one top-level await serialises the entire application startup. It does not. Only the dependency chain above the async module is held back. A sibling subgraph with no path to it evaluates on its own schedule — the module system is free to keep making progress elsewhere while the async module is suspended. Likewise, the async module's own dependencies are *below* it, so they have already finished before its body — and therefore its `await` — ever runs. What is guaranteed in all cases is the ordering that matters for correctness: no module body observes a partially-initialised dependency. ## The whole-graph completion point Something eventually has to observe that the graph is finished. For a dynamic `import('./a.mjs')` that promise resolves with the namespace object only after every async module beneath it has completed. For an entry-point module script, the host simply resumes after the graph settles. That is why a page whose entry module transitively awaits a slow network call shows nothing running from that module tree until the call returns. ## The hazards worth naming **Inherited latency.** The wait is not paid once by the module that wrote the `await` — it is paid by every importer. A shared utility module that awaits a 300 ms request adds 300 ms before *any* consumer's first line runs, and the consumers cannot see where the time went by reading their own code. **Never-settling awaits.** `await new Promise(() => {})` at module top level is the module-system equivalent of an infinite loop: the module's evaluation promise never settles, so its importers simply never run. There is no timeout and no error — the application just does nothing. **Cycles.** Top-level await makes it possible to write a genuine deadlock in a cyclic graph: a module suspends on something the other half of the cycle can only produce after this module has finished evaluating, and neither side can proceed. In a purely synchronous cycle you get an undefined or uninitialised binding, which is bad but visible; with top-level await you can get silence instead. ## How to talk about it The crisp framing is: *top-level await turns a module into a promise-shaped thing, and that shape is contagious upward.* Use it for genuinely one-time setup at or near the entry point — choosing an implementation with a conditional dynamic `import()`, initialising a WebAssembly instance — where the whole program legitimately cannot start without it. Avoid it in shared leaf modules, where importers inherit a cost they never asked for.

  • If a module deep in the graph uses top-level await, how far up does the waiting spread?
    All the way up its import edges. Every direct importer waits, every importer of those importers waits, and so on to the entry point — each of them effectively becomes an async module too. Nothing spreads sideways: a subgraph with no import path to the awaiting module is unaffected.
  • Can top-level await deadlock, and what does that look like?
    Yes. If the awaited promise never settles — `await new Promise(() => {})` is the minimal case — the module's evaluation promise never settles and its dependents never run. There is no error and no timeout, just a silent stall. Cycles make this easy to write by accident when each side waits on something the other can only produce after finishing.
  • Does top-level await change when the module's dependencies run?
    No. Dependencies are below the module in the graph, so they are fully evaluated before its body starts, exactly as without top-level await. The only thing that changes is what happens above: the module's own completion is now asynchronous, so its dependents are deferred.

saying these in an interview costs you the question

  • Says one top-level await blocks the entire module graph
  • Thinks importers may see the binding before the await settles
  • Claims top-level await blocks the thread while waiting
  • Says only the direct importer waits, not transitive ones
  • Believes a never-settling top-level await throws an error

context