cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Trace async evaluationExplain why an importer starts only after an awaited dependency finishes evaluating.
  • 02
    Find the waiting pathSeparate direct and indirect importers from sibling modules that have no dependency path.
  • 03
    Choose 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.

DefinitionThink of top-level await as a readiness promise that the module system manages for you. The importer's ordinary statements do not run until the dependency is ready.

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.

Step through an awaiting dependency
Step 0 of 5Ready
Your turn: follow the blue line

A replay of instrumented lesson code. It shows the dependency relationship, not a JavaScript engine debugger.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const replayTheme = await Promise.resolve("dark");console.log("settings done");console.log("main sees", replayTheme);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

A teaching model for waiting modulesPop out in the code editor (opens in a new tab)JavaScript
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.

Step through who waits in the graph
Step 0 of 4Ready
Your turn: follow the blue line

A replay of the lesson's dependency model. It computes which module bodies wait; it does not reproduce the full specification algorithm.

Running in
  1. script
Next: line 2
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(","));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyGroup photo waiting
A group photo waits for one late friend. Everyone who must be in that photo waits with them. People who are not in the photo carry on with their day.
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.

Three files: settings, logger, and mainJavaScript
// 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.

Relative order, not a timerDo not promise a timing value from this example. The meaningful guarantee is the relative order: logger can run before settings finishes, and main runs after settings finishes.

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.

A small delayed configuration moduleJavaScript
// 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.

Ways a module can become ready
PatternWhere waiting happensWho is delayed
Top-level awaitThe module body pauses until its awaited promise settles.Direct and indirect importers wait before their bodies run.
An unrelated siblingIt 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 requestThe 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.

Playground: toggle top-level await in settings
The graph controlled hereJavaScript
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");
Model result
waiting modulesmain.js, settings.js
proved ordersettings start -> logger runs -> settings done -> main runs
Try it yourself

settings.js and main.js wait. logger.js has no dependency path, so it can run while settings is paused.

This is a teaching model of waiting paths. The proved order below comes from the equivalent real multi-file Node example.

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.

Sort the graph consequences
  • settings.js, which contains top-level await.
  • main.js, which imports settings.js.
  • feature.js, which imports main.js.
  • logger.js, which has no path to settings.js.
  • A small configuration every screen needs before it is usable.
  • A checkout-only feature on the home-page startup path.
Try it yourself
0 of 6 correct

Place each card by its role in the settings example or by the design choice it suggests.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Lazy initialization for optional workPop out in the code editor (opens in a new tab)JavaScript
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 and after: a map library chooses who waitsPop out in the code editor (opens in a new tab)JavaScript
// 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.

Statements to separate
ClaimAccurate meaningEvidence in this lesson
Top-level await blocks every moduleOnly importers on the dependency path wait.logger.js can run while settings.js is awaiting.
The import statement runs like a function callImports describe graph edges before bodies evaluate.The dependency's body runs before the importer's ordinary statements.
A slow await blocks fetchingIt affects evaluation after loading and linking.A sibling already in the graph can still evaluate.
It is a general startup defaultIt 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.

Exercise 1 · Warm-upPredict the first output
In the settings example, predict the first printed line. Answer with the complete text, not a description.

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the waiting modules
    For main importing settings and logger, name the two modules that wait when settings contains top-level await.

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeExplain the sibling
      Explain in one short sentence why logger can run before settings finishes.

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeChoose a library API
        A map library fetches optional style data. Name an API shape that avoids delaying every module that imports the library.

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeApply it to checkout
          A shopping app has a checkout-only module. What should load it when the user starts checkout?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeChoose required setup
            State when top-level await is a reasonable choice for configuration in a real app.

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz

              Check the exact graph rule, then the practical choice. For code questions, predict what the JavaScript value is before selecting an answer.

              Top-level await quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does top-level await make a module act like?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const theme = await Promise.resolve("dark");
                console.log(theme);

                Choose an answer to see the explanation.

              3. Question 3 of 7Which body waits when main imports settings and settings awaits?

                Choose an answer to see the explanation.

              4. Question 4 of 7What order is proved for an awaiting settings module and unrelated logger?

                Choose an answer to see the explanation.

              5. Question 5 of 7Where is a slow top-level network request risky?

                Choose an answer to see the explanation.

              6. Question 6 of 7What is a good alternative for optional work?

                Choose an answer to see the explanation.

              7. 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() or init() for optional startup work.

              Top-level await makes a module's readiness part of its import contract.

              Coming next: Module loading in browsers.

              CompleteFrontend Clear concepts. Working examples.