completefrontend
Open lab My progress
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
    Follow the order files run inExplain why a file that imports a module with top-level await starts only after that module is done.
  • 02
    Find the files that waitTell apart the files that import the waiting module, directly or through other files, from the files that don't.
  • 03
    Choose how to set up a moduleUse top-level await for setup that every file needs. Use import() or an init() function for optional work.

A module that waits

A module is a JavaScript file that uses import or export. Usually you can only write await inside an async function.

Top-level await means writing await directly in a module, outside any function. "Top level" means the outer part of the file.

It lets a module finish its setup before other files use it. A file that imports this module waits. Its own code starts only after the module is done.

A module graph is all the files of an app, joined by their import lines. An import line at the top of a file is called a static import. JavaScript runs an imported file first. Then it runs the file that imports it. Top-level await keeps this rule. The only change is that the imported file may take longer to finish.

DefinitionTop-level await is an await written directly in a module. Every file that imports that module waits until the module is done.

This lesson uses small files, because the waiting depends on how the files import each other. The next lesson, Module loading in browsers, shows how the browser finds and downloads the files before any of them run.

A module with await runs in two parts

Start with one file, settings.js. It has three lines:

  • Line 1 prints a message.
  • Line 2 waits for a promise. A promise is a value that arrives later. This one already holds "dark".
  • Line 3 prints a second message after the value has arrived.
settings.js waits on line 2Pop out in the code editor (opens in a new tab)JavaScript
console.log("settings start");
export const theme = await Promise.resolve("dark");
console.log("settings done");

Line 1 prints settings start. On line 2, await pauses the file until the promise gives its value, "dark". Then line 2 stores it in the exported name theme. Line 3 prints settings done.

Now a second file, main.js, imports theme. It does not need its own await. When its code starts, theme is already ready.

main.js imports the ready themePop out in the code editor (opens in a new tab)JavaScript
import { theme } from "./settings.js";
console.log("main sees", theme);

Line 1 imports theme from settings.js. Line 2 prints main sees dark. This line prints after both messages from settings.js.

main.js imports settings.js. settings.js runs first and pauses at its await. main.js runs second, after settings.js is done.main.jsimportssettings.js2. runs aftersettings.js is done1. runs first,pauses at await
main.js imports settings.js, so settings.js runs first. Its await pauses it, and main.js waits until it is done.

The step-through below puts both files in one listing. Lines 1 to 3 are settings.js. Line 4 is the log from main.js.

Step through settings.js and main.js
Step 0 of 5Ready
Your turn: follow the blue line

This replay puts both files in one listing. It shows the order the files run in. It is not a real debugger.

Running in
  1. script
Next: line 1
const replayTheme = await Promise.resolve("dark");console.log("settings done");console.log("main sees", replayTheme);
CallStoreChangeResultBlue line: runs next. 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

A module with top-level await waits for itself first. Then every file that imports it waits too. A file waits even if it imports the module through another file. So the waiting climbs up the graph, from the imported file to the files that import it.

The code below is a small model that works out which files wait. It is not the real code inside JavaScript. It keeps a list of waiting files. It adds a file to the list when that file imports a file that is already on the list.

A small model that finds the waiting filesPop 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(","));

In plain words, line by line:

  • Line 2 makes a Set, a list with no repeats, called waiting. It starts with the file that has the await.
  • Lines 4 and 5 repeat the search until a full pass adds no new file.
  • Line 6 goes through every file and the list of files it imports. Object.entries gives each file name with its imports.
  • Line 7 asks: does this file import a waiting file, and is it not on the list yet? .some is true if at least one import matches.
  • Lines 8 and 9 add the file and note that the list changed.
  • Line 13 returns the list in alphabetical order.
  • Lines 16 to 20 describe the app: main.js imports settings.js and logger.js.

Line 21 prints main.js,settings.js. So main.js waits, and logger.js does not.

A graph of three files. main.js imports settings.js and logger.js. settings.js has an await, so it waits. main.js imports it, so main.js waits too. logger.js does not wait.main.jswaitssettings.jswaits:it has awaitlogger.jsdoes not wait:no path to settings
An arrow means "imports". settings.js and main.js wait. logger.js does not import settings.js, so it does not wait.
Step through which files wait
Step 0 of 4Ready
Your turn: follow the blue line

This replays the small model from the lesson. It finds which files wait. It is not the real code inside JavaScript.

Running in
  1. script
Next: line 2
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(","));
CallStoreChangeResultBlue line: runs next. 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 analogyWaiting for a group photo
A group photo waits for one friend who is late. Everyone who must be in that photo waits with them. People who are not in the photo go on with their day.
In real life: The friend who is late
In JavaScript: settings.js at its await
In real life: People who must be in the photo
In JavaScript: files that import settings.js, directly or through another file
In real life: People who are not in the photo
In JavaScript: files that do not import settings.js, like logger.js

Files that don't import it keep going

Two files imported by the same file are called siblings. The order of the import lines does not make one sibling wait for the other. Here main.js imports both settings.js and logger.js. 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 four lines always print in this order:

  1. settings start (line 2)
  2. logger runs (line 7)
  3. settings done (line 4)
  4. main runs (line 12)

settings.js pauses at its await on line 3. While it is paused, JavaScript runs logger.js.

A console with four lines in order: settings start from settings.js, logger runs from logger.js, settings done from settings.js, main runs from main.js.consolesettings startsettings.jslogger runslogger.jssettings donesettings.jsmain runsmain.js
logger runs prints while settings.js is paused at its await. main runs prints last, after settings.js is done.

This helps an app start faster. Setup files that don't need the waiting file can run in the meantime. But a file that imports the waiting file still waits for its exports.

Only the order is sure, not the timeThis example does not tell you how many milliseconds anything takes. What it does promise is the order. logger.js can run before settings.js is done. main.js runs after settings.js is done.

A slow await holds up every file above it

A slow await in a file holds up every file that imports it, directly or through other files. That is useful when the app really cannot work before something is ready. Some examples:

  • a setting every page needs,
  • a connection to a server,
  • a WebAssembly module, which is fast compiled code that JavaScript can load and call.
app.js waits for a slow config.jsJavaScript
// 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. setTimeout calls done after 20 milliseconds, and that ends the wait. The output is always in this order: config start, config done, then app starts. The exact delay can change from run to run. The order does not.

Sometimes only one feature needs the slow work. Then move the await into that feature, lower in the graph. The rest of the app can start without waiting for work the user may never ask for.

The table compares ways to get a module ready. Each row is one way. Read across to see where the waiting happens and which files are held up.

Ways to get a module ready, and who waits
What you writeWhere the waiting happensWhich files wait
Top-level await in a module.The module pauses at the await until the promise gives its value.Every file that imports it, directly or through other files, waits before its code runs.
A sibling file that doesn't import the waiting module.It does not wait at all.No file waits for it. It can run while the waiting module is paused.
Load a feature with import().import() starts loading only when the code calls it.No file waits for the feature when the app starts.
Offer an init() function.The calling code decides when to call init() and wait.Importing the module is quick. Only the code that calls init() waits.
A library makes a slow request with top-level await.The library starts the request while it runs.Every file that imports the library waits, even pages that never use the feature.

Try the waiting graph

The playground has one button. It switches the await in settings.js on or off. The results show:

  • which files wait, from the model above,
  • the order the four lines print, from the real three-file example.

Read the code first. Then press the button and compare both results. Reset turns the await back on.

Playground: turn the await in settings.js on or off
The code the button changesJavaScript
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");
Results
files that waitmain.js, settings.js
print ordersettings start -> logger runs -> settings done -> main runs
Try it yourself

settings.js and main.js wait. logger.js does not import settings.js, so it runs while settings.js is paused.

The list of files that wait comes from the small model above. The print order comes from running the real three files.

When to use top-level await

Top-level await fits best when every file that imports a module needs it to be ready first. Two examples:

  • a small config loaded once, which every page reads,
  • a WebAssembly module that every importing file calls.

It also prevents a common bug. Some code starts setup inside an async function and calls it straight away, without await. Then another file can read an export before the setup is done. It gets a value that is not ready yet. With top-level await, JavaScript makes every importing file wait for the setup.

Keep the awaited work small and needed by everyone. Also plan for failure. If the promise fails (is rejected), the files that import the module don't run. So show the user a clear error when startup fails.

Who waits, who keeps going, and what to choose
  • 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

Put each card where it belongs. The first cards are files from the settings example. The last cards are choices for a real app.

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 is shared code that many files import. Suppose a library awaits a slow network request at the top level. Then every file that imports it waits for that request when the app starts. That is a bad fit for work most users don't need, such as:

  • extra data a page may never show,
  • analytics, which records what users do,
  • checkout code,
  • a screen that many visitors never open.

There are two better choices:

  • Use import() when a feature should load only when it is needed. import() is a function that loads a module when it is called and gives back a promise.
  • Use an init() function when the calling code should decide when to start the setup. Then it can also show a loading message while it waits.
Load the config only when checkout startsJavaScript
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 4 starts a request for the config only when loadConfig() is called. The ??= operator stores the request only if configPromise is still empty. So a second call reuses the same request. Line 9 waits inside startCheckout(). So the rest of the app does not wait for checkout.

This example needs a web server that sends /config.json. That is why you can't run it in the editor here.

A map library lets callers choose when to waitPop out in the code editor (opens in a new tab)JavaScript
// The map styles start loading only when a map opens.
let stylesPromise;
export function init() {
  stylesPromise ??= Promise.resolve("loaded");
  return stylesPromise;
}
export async function openMap() {
  console.log("map styles", await init());
}
console.log("home page is already ready");
openMap();

Imagine the library awaited its map styles at the top level. Then even a login page that imports it would wait for map styles it never uses. Here, line 4 starts loading the styles only when someone calls init(). openMap() calls it on line 8.

Lines 10 and 11 show the result. home page is already ready prints first. Then map styles loaded prints. Two maps that both call init() still share one promise, so the styles load only once.

Left: with top-level await in the map library, the home page and openMap() both wait for styles. Right: with init() on open, the home page runs now and only openMap() waits for styles.top-level awaithome pagewaits for stylesopenMap()waits for stylesinit() on openhome pageruns nowopenMap()waits for styles
With a top-level await in the library, every page that imports it waits. With init(), only openMap() waits for the styles.

Top-level await is not bad. Use it when every file that imports a module needs the same thing to be ready. Keep optional work inside the feature that uses it.

Common mistakes

The word blocking can sound like the whole browser freezes. Here it means something smaller. A file that imports a waiting module does not run until that module is done. Files that don't import it can still run.

A static import line is not like a function call that runs at that exact spot. It only says which file this file needs. JavaScript first finds and connects all the files. Then it runs them, imported files first.

Each row of the table is a common belief. Read across to see what is really true and where this lesson shows it.

Common beliefs about top-level await, and what is true
What people thinkWhat is trueWhere this lesson shows it
Top-level await makes every module wait.Only files that import the waiting module, directly or through other files, wait.logger.js runs while settings.js is paused.
An import line runs like a function call.An import line only says which file is needed. JavaScript connects all the files before any of them run.The code in settings.js runs before the code in main.js.
A slow await stops files from downloading.Files are found and downloaded first. The await only matters later, when the files run.logger.js is already loaded, so it can run during the pause.
Top-level await is a good default for all startup work.It makes every importing file wait for that work.That costs time when the work is slow or optional, like the map styles.

Myth: top-level await is a free way to replace every startup promise.

Reality: it makes every importing file wait. That is right when they all need the value. It is costly when the work is slow or optional.

Practice exercises

Use the file names from the examples in your answers. The first exercises ask which files wait. The last ones ask what to choose in a small app.

Exercise 1 · Warm-upPredict the first output
Look at settings.js. What is the first line it prints? Type the exact text.

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

    Exercise 2 · Warm-upName the waiting files
    main.js imports settings.js and logger.js. settings.js has a top-level await. Name the two files that wait.

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

      Exercise 3 · PracticeExplain why logger.js keeps going
      In one short sentence, say why logger.js can run before settings.js is done.

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

        Exercise 4 · PracticeChoose what a library offers
        A map library downloads style data that only maps need. What can the library offer, so that files that import it don't all wait?

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

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

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

            Exercise 6 · ChallengeChoose when setup must come first
            In a real app, when is top-level await a good choice for loading a config?

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

              Quiz

              The first questions check which files wait. The last ones check what to choose in a real app. For code questions, decide what prints before you pick 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

              • A module with top-level await pauses at that line. Files that import it wait until it is done.
              • A file waits if it imports the waiting module, directly or through other files. The order of the import lines does not matter.
              • Files that don't import the waiting module can run while it is paused.
              • Use top-level await for setup that every importing file needs. Use import() or init() for optional work.

              With top-level await, a module is ready only when its setup is done, and every file that imports it waits for that.

              Coming next: Module loading in browsers.

              CompleteFrontend Clear concepts. Working examples.