Skip to main content

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 await was allowed only inside an async function, so initialisation was wrapped into init() or bootstrap() helpers.
  • Now await can 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 .js file with "type": "module" in package.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 await blocks the start of the whole dependency tree.

Quick example

javascript
// 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:

javascript
// 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 .mjs extension, or
  • in .js, if package.json declares "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:

  1. The module becomes asynchronous.
  2. Its execution pauses until the await settles.
  3. Other modules that import this module also wait until it finishes, through the dependency chain.

Example 1, an import that waits:

javascript
// user.mjs export const user = await fetch('/api/user').then(r => r.json());
javascript
// 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:

javascript
// 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:

javascript
A start A done B start

Practical examples

Example 2, dynamic config initialisation:

javascript
// 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:

javascript
// 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:

json
{ "type": "module" }
javascript
// 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.
FeatureDescription
What it doesLets you use await outside async functions
Where it worksIn ES modules only
What it returnsThe module becomes asynchronous and returns a promise when imported
ApplicationsAsynchronous initialisation, dynamic imports
RisksBlocked module loading, unavailable in CommonJS

Common mistakes

  • Trying to use top-level await in CommonJS. In a file without "type": "module" or without the .mjs extension 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 await delays 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...catch with a fallback value.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.