Top-level await
Top-level await is the ability to use await directly at the top level of an ES module, outside any async function. A module with such an await becomes asynchronous itself: its loading pauses until the promise settles.
Theory
TL;DR
- Previously
awaitwas allowed only inside anasyncfunction, so initialisation was wrapped intoinit()orbootstrap()helpers. - Now
awaitcan be written at the top level of a module, and the module simply waits on that line. - It works only in ES modules:
.mjs, a.jsfile with"type": "module"inpackage.json, or<script type="module">. It is not available in CommonJS. - A module that uses top-level await effectively returns a promise when imported, so everyone importing it waits too.
- Main uses: asynchronous initialisation, reading configs, dynamic imports with
await import(...). - Main risk: one long
awaitblocks the start of the whole dependency tree.
Quick example
// module.mjs or a file in a project with type: "module"
const response = await fetch('https://api.example.com/user');
const user = await response.json();
console.log('User:', user.name);The code pauses the loading of the module until the promise settles. That is exactly what makes the module asynchronous by nature.
How it used to be
Before top-level await, await could be used only inside an async function:
// This used to be an error
const data = await fetch('/api/data');
// This one worked
async function main() {
const data = await fetch('/api/data');
console.log(await data.json());
}
main();If you needed to wait for something at the beginning of a module, you had to create a wrapper function (init(), bootstrap() and the like) and remember to call it.
Where you can use it
Only in ES modules (ESM), that is in files:
- with the
.mjsextension, or - in
.js, ifpackage.jsondeclares"type": "module", or - in
<script type="module">in the browser.
In CommonJS (Node.js with require) it is not possible.
How it works under the hood and how it affects other modules
When a bundler or a runtime meets await at the top level of a module:
- The module becomes asynchronous.
- Its execution pauses until the
awaitsettles. - Other modules that import this module also wait until it finishes, through the dependency chain.
Example 1, an import that waits:
// user.mjs
export const user = await fetch('/api/user').then(r => r.json());// main.mjs
import { user } from './user.mjs';
console.log('User name:', user.name);Here main.mjs will not start executing until user.mjs has finished its await.
If module A uses a top-level await and module B imports A, then B waits for A as well:
// a.mjs
console.log('A start');
await new Promise(r => setTimeout(r, 1000));
console.log('A done');
// b.mjs
import './a.mjs';
console.log('B start');Output:
A start
A done
B startPractical examples
Example 2, dynamic config initialisation:
// config.mjs
const env = await fetch('/env.json').then(r => r.json());
export const API_URL = env.production ? 'https://api.example.com' : 'https://api.staging.example.com';The config module can now be imported like any other, while it pulls the configuration asynchronously on its own.
Example 3, top-level await with a dynamic import:
// main.mjs
const lang = navigator.language.startsWith('fr') ? 'fr' : 'en';
const messages = await import(`./messages.${lang}.js`);
console.log(messages.default.hello);This lets you import a module dynamically with asynchronous syntax, without extra wrappers.
An example in Node.js:
{
"type": "module"
}// index.js
import fs from 'fs/promises';
const config = JSON.parse(await fs.readFile('./config.json', 'utf-8'));
console.log('Config:', config);This works because Node.js supports top-level await in ESM mode.
Benefits, risks and a summary
Benefits:
- It simplifies module initialisation: no
async init()wrapped around the code. - It removes the "import pyramid" for asynchronous dependencies.
- It allows flexible asynchronous imports with
await import().
Risks:
- Blocking the module chain: if one module hangs on a long
await, the whole dependency tree waits for it. - Not available in CommonJS: ESM only.
- It can affect application startup time, especially in Node.js.
| Feature | Description |
|---|---|
| What it does | Lets you use await outside async functions |
| Where it works | In ES modules only |
| What it returns | The module becomes asynchronous and returns a promise when imported |
| Applications | Asynchronous initialisation, dynamic imports |
| Risks | Blocked module loading, unavailable in CommonJS |
Common mistakes
- Trying to use top-level await in CommonJS. In a file without
"type": "module"or without the.mjsextension it is a syntax error; the project or the file has to move to ESM first. - Forgetting
<script type="module">. In a plain<script>top-level await does not work, because that is not a module. - Making long network requests at the top level of a frequently imported module. Such an
awaitdelays the start of everyone importing it, and the application shows nothing for a long time. Better to move it into an explicit initialisation function or to export the promise. - Assuming top-level await makes imports parallel. Modules in a chain wait for one another; for parallel requests inside a single module you need
Promise.all. - Creating circular dependencies with top-level await. If two modules wait for each other, execution never completes.
- Not handling the error. A rejected promise at the top level fails the loading of the entire module, so critical spots are worth wrapping in
try...catchwith a fallback value.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.