JavaScriptTop-Level await

Top-level await

Before ES2022, await only worked inside an async function. That meant if you wanted to fetch a config file or open a database at the top of a module, you had to wrap everything in an immediately-invoked async function. Top-level await removes that wrapper — inside an ES module, you can await directly at the top of the file.

The old wrapping dance

Before TLA, the canonical way to use async work at module load was an IIFE — the immediately-invoked async function expression:

Before — wrap everything in an async IIFE

JS
// config.js
let config;

(async () => {
  const res = await fetch("/config.json");
  config = await res.json();
})();

export { config };   // races: importer may see `undefined`

The export here is broken: anyone importing config may read it before the IIFE has finished. The wrapper hides the await, but it doesn't actually delay anything for the importer.

With top-level await

After — await at module scope

JS
// config.js  (ES module)
const res = await fetch("/config.json");
export const config = await res.json();

This is shorter, but the real magic is what happens at the import side. The module system now treats config.js as still loading until the awaits finish. Any module that imports it waits too.

The importer is unaware anything async happened

JS
// app.js
import { config } from "./config.js";

console.log(config.apiBase);   // always defined
Where it works
  • ES modules (<script type="module"> in browsers, .mjs files in Node, or .js in a package with "type": "module").

  • Inside Node REPL with --experimental-repl-await (now default in modern Node).

  • Inside the Chrome DevTools console, which wraps your input in an async context.

Not in CommonJS
You cannot use top-level `await` in a `.cjs` file or a script tag without `type="module"`. The syntax is a hard error there — there is no async wrapper for the file to suspend.
Real uses

One-line versions of patterns that used to be ugly:

Conditional import

JS
// Pick an implementation at load time.
const strings = await import(
  navigator.language.startsWith("fr") ? "./fr.js" : "./en.js"
);

export const t = strings.default;

Database connection on startup

JS
// db.js
import { connect } from "./driver.js";

export const db = await connect(process.env.DATABASE_URL);
// Any module importing `db` is guaranteed it is ready.

Fallback resource

JS
let translations;
try {
  translations = await fetch("/i18n/" + lang + ".json").then(r => r.json());
} catch {
  translations = await fetch("/i18n/en.json").then(r => r.json());
}
export default translations;
How it changes module loading

A module that uses top-level await is called an async module. The runtime treats it specially:

  • The module starts running as usual.

  • When it hits an await, evaluation pauses.

  • Anyone importing it gets a pending promise — their evaluation also pauses until the awaited value resolves.

  • Sibling modules that do not depend on it can keep running in parallel.

So TLA does not block your whole app — it blocks only the dependency graph downstream of the awaiting module.

Browser and Node support
  • Chrome / Edge 89+, Firefox 89+, Safari 15+ — all modern browsers.

  • Node.js 14.8+ in .mjs files, fully stable from 16 onwards.

  • Bundlers — Webpack 5+, Vite, Rollup, esbuild all support it. Some, like Webpack, need a config flag.

Quick demo with order

JS
// a.mjs
console.log("a: start");
await new Promise(r => setTimeout(r, 100));
console.log("a: done");
export const x = 1;

// b.mjs
console.log("b: start");
import { x } from "./a.mjs";
console.log("b: got", x);
a: start
a: done            (~100ms later)
b: start
b: got 1
Things to watch out for
Don't await inside a hot loop at module scope
Each `await` at the top level adds latency to your module's load time. Fine for a config fetch, painful for a 50-iteration loop. Move loops into a function and `await` the function once.
  • A module that always throws on load becomes an unimportable dependency. Wrap risky awaits in try/catch with a fallback.

  • Circular dependencies through async modules can deadlock if A awaits B which awaits A.

  • Top-level await runs only once per module (modules are cached). The result is shared across all importers.

One less wrapping
That is really the whole point. The async IIFE workaround is gone. Your module-level code can use `await` the same way function-level code does, and importers do not need to know.
Tip
Prefer top-level `await` for *initialisation* — fetching config, opening connections, picking a locale. Avoid using it for everyday work that could run inside a function called later.