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
// 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
// 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
// app.js
import { config } from "./config.js";
console.log(config.apiBase); // always definedWhere it works
ES modules (
<script type="module">in browsers,.mjsfiles in Node, or.jsin 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.
Real uses
One-line versions of patterns that used to be ugly:
Conditional import
// 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
// 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
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
.mjsfiles, 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
// 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
A module that always throws on load becomes an unimportable dependency. Wrap risky awaits in
try/catchwith 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.