skip to content

Top-Level Await

Awaiting directly in a module body makes that module async and holds its importers' evaluation until it settles. Interviewers probe the ordering consequences, the deadlock risk inside cycles, and why CommonJS cannot offer it.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In an ES module, what does writing `await` directly in the module body (outside any function) do to that module and to the modules that import it?

level: middleimportance: must knowfreq 62%

answer

  1. makes the module asynchronous
  2. body splits at the await
  3. importers wait to evaluate
  4. asynchrony spreads up the graph
  5. rejection fails every importer

basics

~10 s

Top-level await marks the module as asynchronous: its body runs only up to the await, resumes when the awaited promise settles, and every module importing it has its own evaluation held until that finishes.

solid answer

~40 s

Standardized in ES2022, `await` is legal directly in a module body, and it changes how that module is evaluated. The body executes synchronously up to the first `await`, then suspends; the rest runs later, once the awaited promise settles. Because the module's evaluation is now asynchronous, evaluating the module graph produces a promise instead of finishing in one synchronous pass. Any module that imports the async one does not start its own body until that dependency has fully evaluated, and this propagates upward — one top-level `await` deep in the tree delays every ancestor. The payoff is that imported bindings are guaranteed ready, so importers never observe a half-initialized export. The costs are startup latency and error handling: if the awaited promise rejects, the importers' evaluation fails with that same error.

code

javascript · 3 lines
javascript
const config = await Promise.resolve({ retries: 3 });
console.log('module body resumed');
export { config };

go deeper

for a junior

Know that await is allowed at the top of an ES module, that the module below the await runs later, and that whoever imports the module waits for it. Say plainly that this is a module-only feature.

for a middle

Be ready to explain the mechanics: the body splits at the await, the module's evaluation becomes promise-based, and importers do not start until it finishes. An interviewer expects you to note that this propagates up the entire dependency chain.

for a senior

Show that you treat it as a startup-latency and failure-mode decision: a slow or failure-prone await in a leaf module delays and can fail the whole graph, and a static importer has no way to catch that. Explain how you would notice it in a real slow-boot investigation.

for a principal

Own the tradeoff between correctness-by-construction and coupling: top-level await removes an entire class of half-initialized-export bugs but makes every consumer's load path depend on that operation succeeding quickly. Be able to say when you would accept that and when you would push the async work behind an explicit call instead.

## The feature A file evaluated as an ES module may use `await` at the top level of its body, outside any function. This was standardized in ES2022; before that, `await` was a keyword only inside an `async` function, and using it at a module's top level was a syntax error. ```javascript // settings.js — an ES module export const settings = await loadSettings(); console.log('settings module finished evaluating'); ``` ## Why this is structural, not just syntax An ES module is processed in three phases. First the source is parsed and its import/export structure is read. Then the graph is linked: every module's imported bindings are connected to the exporting module's bindings, and this phase is entirely synchronous. Finally the graph is evaluated — the module bodies actually run, dependencies first. Before top-level await, evaluation of a whole graph was one synchronous pass: by the time the entry module's first statement ran, every module beneath it had run to completion. Top-level await breaks that guarantee of synchrony. A module containing a top-level `await` is flagged as having one, and evaluating it now yields a promise. So does evaluating any module that depends, directly or transitively, on such a module — asynchrony is contagious upward through the graph. ## What happens at the await The module body starts running normally. When control reaches the first top-level `await`, the body suspends exactly the way an `async` function body suspends: the statements above it have already run and their side effects are visible, the statements below it have not run, and any exported binding they were supposed to assign is still uninitialized. Control returns to the engine, which continues evaluating other parts of the graph that do not depend on this module. When the awaited promise settles, the remainder of the body runs and the module is finally marked as evaluated. ## The effect on importers The evaluation rule is unchanged in spirit: a module's body does not begin until all of its dependencies have finished evaluating. Top-level await just makes "finished" a later moment in time. Concretely, if `main.js` imports `settings.js` and `settings.js` awaits a slow load, none of `main.js`'s statements run until that load settles. ```javascript // main.js import { settings } from './settings.js'; // this line runs only after settings.js has fully evaluated console.log(settings.retries); ``` This is the actual value of the feature. Without it, a module that needs an async value has to export something that is not ready yet — a promise, or a binding that gets filled in later — and every consumer has to know about that protocol and defend against reading it too early. With top-level await, the binding is simply correct by the time anyone can read it, and the ordering is enforced by the module system rather than by convention. The same rule is why the cost is real. The delay is not local: it is paid by every ancestor in the graph, including the entry module. A top-level await buried three levels down in a dependency delays the entry point by exactly as much as if it were written there. On a page or a process whose startup path imports that graph, this shows up as time where nothing at all has run. ## Rejection If the awaited promise rejects, or the body throws after resuming, the module's evaluation fails. Every module that statically imports it fails to evaluate too, with that same error propagating upward. A static `import` gives you no place to put a `catch` — the failure surfaces as an unhandled module-evaluation error at the host level, and the module record remembers its failure, so importing it again reports the same error rather than re-running the body. Making a module's evaluation depend on an operation that can fail (a network fetch, reading a file) therefore makes the whole graph's load path fail with it, and that is a deliberate design decision, not an incidental detail. ## What it does not do Top-level await does not block the thread. It suspends one module's evaluation and returns control to the engine, so other work continues. It is also not equivalent to wrapping the body in an async IIFE: with an IIFE, the module itself finishes evaluating immediately and its importers run right away, seeing exports that have not been assigned yet. The whole point of the top-level form is that the importer waits.

  • If the promise a module top-level awaits rejects, what do the modules that statically import it see?
    Their own evaluation fails with that same error, because a module cannot begin until its dependencies have finished evaluating and this one finished by failing. A static `import` offers no place to catch it, so it surfaces as a module-evaluation error at the host level. The failed module record stays failed, so importing it again reports the same error instead of re-running the body.
  • Does a top-level await three levels deep in the dependency tree delay only its direct importer, or the entry module too?
    The entry module too. Asynchrony propagates upward: a module that depends on an async module becomes async-evaluating itself, and so does its importer, all the way to the root. The entry point waits for the full settle time of the deepest await on its path, which is why a slow await in an obscure leaf module can silently dominate startup latency.
  • Why is a top-level await not the same as wrapping the module body in an async IIFE?
    With an async IIFE the module finishes evaluating immediately — the IIFE only schedules work — so importers run right away and read exports that have not been assigned yet. Top-level await makes the module's own evaluation asynchronous, so the module system holds importers back until the value exists. The IIFE moves the race into user code; the top-level form removes it.

It is like a recipe step that says "wait for the dough to rise": the cook is not frozen and can do other prep, but nothing that needs the risen dough can start until it has.

saying these in an interview costs you the question

  • Says top-level await blocks the whole thread until it settles
  • Thinks importers evaluate anyway and just see an empty binding
  • Claims it is only syntax sugar for an async IIFE
  • Assumes a rejected top-level await is silently ignored by importers
  • Believes only the direct importer waits, not the entry module

context

open as a page

Why is `await` outside any function a SyntaxError in a CommonJS file or a classic `<script>`, but legal in an ES module?

level: juniorimportance: should knowfreq 48%

basics

~20 s

Top-level await exists only in the ES module goal, added in ES2022. A classic script or a CommonJS file is parsed as a script, where await is not a keyword outside an async function, and CommonJS loading is synchronous so a module could not suspend anyway.

open as a page

Given `a.js` containing `console.log('a1'); await null; console.log('a2');`, `b.js` containing `console.log('b');`, and `main.js` containing `import './a.js'; import './b.js'; console.log('main');`, what does running `main.js` as an ES module print, and why?

level: middleimportance: should knowfreq 40%

basics

~10 s

It prints a1, b, a2, main. The await in a.js suspends only that module, so the independent sibling b.js evaluates immediately; main.js runs last because it waits for both dependencies to finish.

open as a page

Two ES modules import each other, and one of them top-level awaits a value the other only produces after its own body finishes. What happens when the application loads, and how would you track it down?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Nothing happens: the graph's evaluation promise never settles, so the modules stay half-evaluated and the entry point never runs. There is no error, no stack trace and no timeout — the load simply hangs, which is why it is diagnosed by tracing where logging stops.

open as a page

When is top-level await in an ES module the right way to do async initialization, and when would you instead export an initializer function or a promise?

level: principalimportance: should knowfreq 26%

basics

~20 s

Use top-level await when a module's export is meaningless until an async step completes and every consumer needs it — it makes correct ordering structural. Prefer an exported initializer or promise when the module is widely imported, when the work can fail or needs retry control, or when consumers must load synchronously.

open as a page