Top-Level Await in JavaScript, Explained
Top-level await lets a JavaScript module pause at import time without wrapping code in an async function. How it works and when it bites.
Top-level await lets you use the await keyword directly in an ES module’s top-level scope, without wrapping it in an async function. Before it existed, any asynchronous setup at the top of a module — fetching config, opening a database connection, dynamically importing a dependency — had to be stuffed into an immediately-invoked async function or exposed as a promise the caller had to handle. Top-level await removes that ceremony for module code.
// config.js
const response = await fetch("/api/config");
const config = await response.json();
export default config;
Any module that imports config.js automatically waits for it to finish before its own code runs. That’s the core behavior to understand: top-level await doesn’t just pause the current module, it propagates the wait through the whole import graph.
Why this only works in modules
Top-level await is a module-scope feature — it’s not available in regular scripts (<script> without type="module") or inside a plain synchronous function. The reason is structural: ES modules already have an asynchronous loading and linking phase, so adding a pause to that phase is a natural extension. A classic script executes immediately and synchronously as it’s parsed, with no such phase to hook into.
This means the feature is available in:
- Native
<script type="module">tags in the browser .mjsfiles or files in a package with"type": "module"in Node- Dynamic
import()targets, since those are always evaluated as modules
It’s not available in plain CommonJS files (require-based .js under "type": "commonjs"), which is one more reason projects migrating from CommonJS to ESM often list this feature as a motivator — see ESM vs CommonJS for the broader tradeoffs of that move.
How it affects the module graph
The part that trips people up isn’t the syntax — it’s the scheduling. When module A imports module B, and B has a top-level await, A’s evaluation doesn’t proceed until B’s await resolves. If multiple modules in a dependency tree use top-level await, the whole graph’s evaluation order becomes a chain of waits rather than the synchronous walk it used to be.
// db.js
export const client = await connectToDatabase();
// server.js
import { client } from "./db.js";
// server.js does not run a single line until connectToDatabase() resolves
startServer(client);
This is usually what you want for genuine startup dependencies — you don’t want a server accepting requests before its database client exists. But it’s easy to accidentally introduce a slow top-level await deep in a dependency that has nothing to do with startup, and stall an entire app’s boot time without an obvious culprit. If you profile a slow cold start, the module graph — not just your own entry file — is where to look.
Common uses
Dynamic module selection, replacing a runtime require with conditional branching:
const strategy = await (isEdgeRuntime
? import("./edge-strategy.js")
: import("./node-strategy.js"));
Fallback resources, trying a fast path and falling back if it’s unavailable:
let locale;
try {
locale = await import(`./locales/${userLocale}.js`);
} catch {
locale = await import("./locales/en.js");
}
One-time async setup — database clients, WASM module instantiation, or reading a config file before the rest of the module’s exports make sense. This is the same category of problem covered in what a polyfill is for feature detection at load time, except here the detection itself can be asynchronous.
What can go wrong
Deadlocks from circular imports. If module A top-level-awaits something that indirectly depends on module B, and B imports A before A has finished evaluating, you can hit a genuine deadlock — the spec detects some cases and throws a SyntaxError at parse time, but not all of them are easy to reason about in a large graph. Circular imports were already a code smell before top-level await; this feature raises the stakes.
Unbounded startup latency. Every top-level await in the import graph is inline with your app’s boot sequence by default. A slow network call in a rarely-updated dependency can silently become part of every cold start. Prefer lazy initialization (a function you call on first use) over top-level await for anything that isn’t strictly required before the module’s other exports are usable.
Bundler and target support. Bundlers and transpilers need to understand top-level await to preserve its semantics — check your build target if you’re shipping to older runtimes, since down-leveling it usually isn’t possible without changing the module’s shape (turning the whole module into a promise the consumer has to unwrap, defeating the point).
Top-level await vs wrapping in an async IIFE
The alternative before this feature — and still a valid choice when you specifically want to avoid blocking importers — is an async immediately-invoked function expression:
| Top-level await | Async IIFE | |
|---|---|---|
| Blocks importers until resolved | Yes | No — importers get an unresolved reference |
| Syntax overhead | None | Wrapper function required |
| Error propagation | Rejects module evaluation, visible to import graph | Must be handled manually or becomes an unhandled rejection |
| Best for | Genuine startup dependencies | Fire-and-forget async work you don’t want to gate on |
If you want async work to happen without holding up anything that imports your module, top-level await is the wrong tool — kick it off as an async IIFE (or just an unawaited async function call) instead.
The takeaway
Top-level await lets a module pause its own evaluation — and every importer’s — until an async operation resolves, which is exactly right for genuine startup dependencies like a database connection or dynamic module selection, and exactly wrong for background work you don’t want gating your app’s cold start. It only works in ES modules, not classic scripts or CommonJS, and it turns your import graph into a chain of waits, so a slow await buried in a dependency can quietly become part of every boot. Use it for setup that must finish before the module is usable; use a plain async function for everything else.
Tagged
Keep reading
Takina · · 4 min read Structural Typing vs Nominal Typing in TypeScript
Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.
Takina · · 3 min read JavaScript Import Attributes Explained
Import attributes let JavaScript modules declare a resource's expected type at import time, such as JSON — catching mismatches before code runs.
Takina · · 4 min read Web Streams API Explained: Readable, Writable, Transform
The Streams API lets JavaScript process data as chunks arrive instead of buffering it all in memory. How ReadableStream, WritableStream, and pipes work.