Top-level await & async evaluation
Learn how top-level await pauses importing module bodies, lets unrelated siblings run, and when startup code should stay lazy.
- 01Trace async evaluationExplain why an importer starts only after an awaited dependency finishes evaluating.
- 02Find the waiting pathSeparate direct and indirect importers from sibling modules that have no dependency path.
- 03Choose an initialization shapeUse top-level await for required setup and lazy loading or init functions for optional startup work.
An async module
Top-level await is an await directly in an ES module, outside an async function. It lets the module finish setup before code that imports it begins its own module body.
A module graph is a set of files connected by static import statements. Normally the engine evaluates dependencies first and then their importers. An awaited dependency keeps that rule, but the dependency may finish later.
This lesson uses small files because the behavior belongs to the graph, not one statement alone. The next lesson, Module loading in browsers, follows the browser work before evaluation.
Async module evaluation
Start with one file. The first line prints, line 2 waits for an already-resolved promise, and line 3 prints after the value becomes available.
console.log("settings start");
export const theme = await Promise.resolve("dark");
console.log("settings done");Line 1 prints settings start. Line 2 creates the exported theme binding after the promise resolves to dark. Line 3 then prints settings done.
Now an importing file can read the binding without making its own promise protocol. It simply imports a value that is ready when its body starts.
import { theme } from "./settings.js";
console.log("main sees", theme);Line 1 names settings.js as a dependency. Line 2 prints main sees dark, and that output comes after both messages from the dependency.
A replay of instrumented lesson code. It shows the dependency relationship, not a JavaScript engine debugger.
script
const replayTheme = await Promise.resolve("dark");console.log("settings done");console.log("main sees", replayTheme);Which modules wait
An awaiting module waits for itself first. Every module that imports it waits too, directly or through another importer. This is an upward path through the graph.
Use a small teaching model to make that path visible. It is not the engine's specification code; it repeatedly adds a module when one of its imports is already waiting.
function whoWaits(graph, asyncModules) {
const waiting = new Set(asyncModules);
let changed = true;
while (changed) {
changed = false;
for (const [module, imports] of Object.entries(graph)) {
if (imports.some((name) => waiting.has(name)) && !waiting.has(module)) {
waiting.add(module);
changed = true;
}
}
}
return [...waiting].sort();
}
const graph = {
"main.js": ["settings.js", "logger.js"],
"settings.js": [],
"logger.js": [],
};
console.log(whoWaits(graph, ["settings.js"]).join(","));Line 2 starts the set with the async module. Lines 6 through 9 find importers of any waiting module. The final output is main.js,settings.js: main waits, logger does not.
A replay of the lesson's dependency model. It computes which module bodies wait; it does not reproduce the full specification algorithm.
script
function whoWaits(graph, asyncModules) { let changed = true; while (changed) { changed = false; for (const [module, imports] of Object.entries(graph)) { if (imports.some((name) => waiting.has(name)) && !waiting.has(module)) { waiting.add(module); changed = true; } } } return [...waiting].sort();} const graph = { "main.js": ["settings.js", "logger.js"], "settings.js": [], "logger.js": [],};console.log(whoWaits(graph, ["settings.js"]).join(","));- In real life: The late friend
- In JavaScript: settings.js reaching await
- In real life: People in that photo
- In JavaScript: direct and indirect importers
- In real life: People not in the photo
- In JavaScript: unrelated sibling modules
Where the analogy stops: A module graph has precise dependency edges; people can choose to join a photo later.
Unrelated siblings continue
Import order does not silently create a dependency between siblings. Here main.js imports both files, but logger.js never imports settings.js.
// settings.js
console.log("settings start");
await Promise.resolve();
console.log("settings done");
// logger.js
console.log("logger runs");
// main.js
import "./settings.js";
import "./logger.js";
console.log("main runs");The stable order is settings start, logger runs, settings done, then main runs. After settings reaches its await, the engine can evaluate logger while settings is paused.
This matters for startup design. Independent setup can make progress while one dependency waits, but code that imports the awaiting module must still wait for its exported values.
Blocking the graph above it
A slow await near the root delays every importing path above it. That is useful when the application truly cannot work before a required setting, connection, or WebAssembly module is ready.
// config.js
console.log("config start");
await new Promise((done) => setTimeout(done, 20));
console.log("config done");
// app.js
import "./config.js";
console.log("app starts");Line 3 waits for a short timer in this demonstration. The reliable output order is config start, config done, and app starts. The lesson proves order only, never the exact delay.
Move the slow dependency lower when only one feature needs it. Then the rest of the application can become useful without waiting for work that the user may never request.
| Pattern | Where waiting happens | Who is delayed |
|---|---|---|
| Top-level await | The module body pauses until its awaited promise settles. | Direct and indirect importers wait before their bodies run. |
| An unrelated sibling | It has no import path to the awaiting module. | It can evaluate while the awaited module is paused. |
| Dynamic import() | A function starts loading work when it is called. | Static importers do not automatically wait at application startup. |
| Explicit init() | The caller chooses when to await initialization. | The module can load quickly while the caller controls readiness. |
| A top-level library request | The library starts optional work while it evaluates. | Every static importer inherits the wait, even pages that never use the feature. |
Try the waiting graph
Toggle one choice: whether settings.js uses top-level await. The model shows modules with an import path to settings, while the order reminds you that logger is an unrelated sibling.
Reset returns to an awaiting settings module. Read the source first, change the one input, then compare the waiting set and the proved order.
const graph = { "main.js": ["settings.js", "logger.js"], "settings.js": [], "logger.js": [],}; const asyncModules = settingsUsesAwait ? ["settings.js"] : [];console.log(whoWaits(graph, asyncModules).join(",") || "no modules wait");main.js, settings.jssettings start -> logger runs -> settings done -> main runssettings.js and main.js wait. logger.js has no dependency path, so it can run while settings is paused.
Use it deliberately
Top-level await is strongest when a module represents something that must be ready before any importer can correctly run. A small configuration loaded once, or a WebAssembly module every importer needs, can fit that shape.
It also prevents a common race from an immediately invoked async function. With a plain async function, an importer might observe an export before initialization finishes. With top-level await, the module system coordinates readiness.
Keep the awaited work small, essential, and easy to report when it fails. A rejected top-level await means importers cannot evaluate normally, so give startup failures a clear path for handling or reporting.
A static import is not a request to call a function at the point where the text appears. It is a dependency edge. The engine first has enough information to connect the graph, then evaluation gives the dependency its turn before the importer body. Top-level await changes the duration of that turn, not the meaning of the import edge.
An exported binding is especially useful here. `theme` is not copied into main at import time. Main reads the module binding only when its body can finally run. The module system therefore avoids a separate exported promise, a `.then()` chain in each importer, and a race where one caller observes a partly initialized export.
Use the word dependency precisely. `main.js` depends on settings because main imports it. Logger is mentioned beside settings in the root file, but that does not make logger depend on settings. The useful question is always: is there an import path from this module to the awaiting module?
The order example deliberately uses `Promise.resolve()` instead of a network request. It makes the pause real without turning the lesson into a clock measurement. Real programs may use configuration, WebAssembly compilation, or a dynamic import, but the graph rule remains the same: an importing body waits for the dependency to finish.
A direct importer is easy to see: main imports settings. An indirect importer is one more step upward: a feature imports main, and main imports settings. The feature body also waits. This is why a slow awaited module close to a widely used root can have a large startup effect even when the await is written in one small file.
Sibling progress is useful rather than surprising. When settings yields, the engine does not invent an ordering edge from logger to settings. Logger can evaluate because its own dependencies are ready. Once settings resumes, its already-waiting importers continue in their dependency order. Do not build product logic around an accidental timer race; build it around the explicit import relation.
settings.js, which contains top-levelawait.main.js, which importssettings.js.feature.js, which importsmain.js.logger.js, which has no path tosettings.js.- A small configuration every screen needs before it is usable.
- A checkout-only feature on the home-page startup path.
Place each card by its role in the settings example or by the design choice it suggests.
When to avoid top-level await
A library that awaits a slow network request makes every static importer inherit that startup delay. That is a poor fit for optional data, analytics, checkout code, or a screen that many visitors never open.
Prefer a lazy import() when a feature is loaded on demand. Prefer an explicit init() function when the caller should decide when readiness matters and how to show a loading state.
let configPromise;
export function loadConfig() {
configPromise ??= fetch("/config.json").then((response) => response.json());
return configPromise;
}
export async function startCheckout() {
const config = await loadConfig();
console.log("checkout uses", config.currency);
}Line 3 starts one shared request only when loadConfig() is called. Line 8 waits inside the checkout action, so unrelated startup code does not wait for this optional feature.
// Before: every static importer waits for optional work.
const styles = await Promise.resolve("loaded");
export function drawMap() {
console.log("map styles", styles);
}
// After: the caller starts optional work when a map opens.
let stylesPromise;
export function init() {
stylesPromise ??= Promise.resolve("loaded");
return stylesPromise;
}
export async function openMap() {
console.log("home page is already ready");
console.log("map styles", await init());
}In the before version, line 2 runs during library evaluation. A login page that merely imports the library therefore waits for map styles it may never use. In the after version, line 10 starts the shared promise only when a caller opens a map.
Lines 14 and 15 show the useful boundary. The home page can already be ready, then the map feature waits for its own styles. The promise is still shared, so two map views can use the same initialization rather than make two requests.
This is not an argument against top-level await. It is a boundary choice: use it when all importers need the same ready resource, and keep optional work at the feature edge.
There are two separate design questions. First, is this resource required for every importer to be correct? Second, is the resource fast and reliable enough for the startup path that inherits the wait? Required, shared configuration can answer yes to both. Optional personalization or a rarely opened checkout screen usually cannot.
Lazy loading does not mean ignoring readiness. `import()` returns a promise, and an explicit `init()` can return a promise too. The difference is ownership: a feature chooses when to start and await that promise. That lets a page render a useful shell, show its own loading state, retry a feature-specific failure, or avoid work entirely for visitors who never use the feature.
Libraries deserve extra care because their importers are not always visible to the library author. A top-level network call in a common helper can delay a dashboard, a login page, and a background task. Keep library module evaluation cheap unless the library's public contract truly says that all users need a ready shared resource before any export is safe to use.
Top-level await is only valid in modules. A console or developer-tool REPL may offer a convenience form of await, but that is not evidence about a production module graph. Test a real module entry point, as this lesson's child-process check does, whenever evaluation order is important to your application.
Common misconceptions
The word blocking can sound like the whole browser freezes. Here it means an importing module body does not evaluate until its async dependency completes. It does not mean every unrelated module is prevented from evaluating.
Static imports remain declarations of dependencies, not lines that call a function at that exact moment. The graph is loaded and linked before evaluation follows its dependency ordering.
| Claim | Accurate meaning | Evidence in this lesson |
|---|---|---|
| Top-level await blocks every module | Only importers on the dependency path wait. | logger.js can run while settings.js is awaiting. |
| The import statement runs like a function call | Imports describe graph edges before bodies evaluate. | The dependency's body runs before the importer's ordinary statements. |
| A slow await blocks fetching | It affects evaluation after loading and linking. | A sibling already in the graph can still evaluate. |
| It is a general startup default | It makes readiness automatic for every importer. | That cost is harmful for optional or slow work. |
Myth: top-level await is a free replacement for every startup promise.
Reality: it deliberately spreads a readiness requirement to importers, which can be exactly right or unexpectedly expensive.
Practice exercises
Use the examples' names while answering. These exercises ask for the dependency relationship first, then for a design decision in a small application.
console.log("settings start");The log before the await prints first.
settings.js awaits, and main.js imports it, so both wait.
logger.js has no import path to settings.js, so it can evaluate while settings is paused.
export async function init() { /* optional setup */ }An explicit init function makes readiness the caller's choice.
const checkout = await import("./checkout.js");Dynamic import starts checkout code only when checkout is needed.
Top-level await is reasonable when every importer needs required configuration before it can run.
Quiz
Check the exact graph rule, then the practical choice. For code questions, predict what the JavaScript value is before selecting an answer.
Question 1 of 7What does top-level await make a module act like?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst theme = await Promise.resolve("dark"); console.log(theme);Choose an answer to see the explanation.
Question 3 of 7Which body waits when main imports settings and settings awaits?
Choose an answer to see the explanation.
Question 4 of 7What order is proved for an awaiting settings module and unrelated logger?
Choose an answer to see the explanation.
Question 5 of 7Where is a slow top-level network request risky?
Choose an answer to see the explanation.
Question 6 of 7What is a good alternative for optional work?
Choose an answer to see the explanation.
Question 7 of 7Which statement describes the improved map-library API?
Choose an answer to see the explanation.
Key takeaways
- An awaited module becomes asynchronously evaluated, and importing module bodies wait for it.
- Waiting follows direct and indirect import paths, not neighboring import statements.
- Unrelated siblings can continue while an awaited module is paused.
- Use top-level await for required readiness; use
import()orinit()for optional startup work.
Top-level await makes a module's readiness part of its import contract.
Coming next: Module loading in browsers.