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.
- 01Follow the order files run inExplain why a file that imports a module with top-level
awaitstarts only after that module is done. - 02Find the files that waitTell apart the files that import the waiting module, directly or through other files, from the files that don't.
- 03Choose how to set up a moduleUse top-level
awaitfor setup that every file needs. Useimport()or aninit()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.
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.
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.
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, 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.
This replay puts both files in one listing. It shows the order the files run in. It is not a real debugger.
script
const replayTheme = await Promise.resolve("dark");console.log("settings done");console.log("main sees", replayTheme);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.
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, calledwaiting. It starts with the file that has theawait. - 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.entriesgives each file name with its imports. - Line 7 asks: does this file import a waiting file, and is it not on the list yet?
.someis 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.jsimportssettings.jsandlogger.js.
Line 21 prints main.js,settings.js. So main.js waits, and logger.js does not.
settings.js and main.js wait. logger.js does not import settings.js, so it does not wait.This replays the small model from the lesson. It finds which files wait. It is not the real code inside JavaScript.
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 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.
// 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:
settings start(line 2)logger runs(line 7)settings done(line 4)main runs(line 12)
settings.js pauses at its await on line 3. While it is paused, JavaScript runs logger.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.
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.
// 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.
| What you write | Where the waiting happens | Which 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.
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 does not import settings.js, so it runs while settings.js is paused.
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.
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.
Put each card where it belongs. The first cards are files from the settings example. The last cards are choices for a real app.
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.
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.
// 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.
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.
| What people think | What is true | Where 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.
settings.js. What is the first line it prints? Type the exact text.console.log("settings start");The log before the await prints first.
main.js imports settings.js and logger.js. settings.js has a top-level await. Name the two files that wait.settings.js has the await, and main.js imports it, so both wait.
logger.js can run before settings.js is done.logger.js does not import settings.js, directly or through another file. So it can run while settings.js is paused.
export async function init() { /* optional setup */ }An init() function lets the calling code decide when to start the setup and wait for it.
const checkout = await import("./checkout.js");import() loads the checkout code only when checkout is needed.
Top-level await is a good choice when every importing file needs the config before it can run.
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.
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
- A module with top-level
awaitpauses 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
importlines 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()orinit()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.