Top-Level Await
Top-level await lets you use the await keyword directly at the module scope of an ES module, outside of any async function. Before this feature, every await had to live inside an async function, which forced awkward wrapper functions or self-invoking patterns just to initialize a module. With top-level await, a module can pause its own evaluation until a promise settles, making asynchronous setup — loading config, opening a database connection, or choosing a dependency at runtime — read like ordinary synchronous code.
How it works
When the JavaScript engine evaluates an ES module and encounters a top-level await, it suspends evaluation of that module until the awaited promise resolves. Any module that imports it is also blocked until the dependency finishes evaluating. This turns the module into an asynchronous module — the importer transparently waits for it without writing any special code.
Top-level await is available in Node.js 14.8+ and is stable in every modern LTS release (18, 20, 22). It only works in ES modules: files ending in .mjs, or .js files in a package whose package.json declares "type": "module".
// config.mjs
const response = await fetch("https://api.example.com/config");
const config = await response.json();
export default config;
// app.mjs
import config from "./config.mjs";
// By the time this line runs, config is fully loaded.
console.log("Service URL:", config.serviceUrl);
Output:
Service URL: https://api.example.com/v2
The importing module never sees a pending promise — the runtime guarantees config is resolved before app.mjs continues.
Blocking module evaluation
A top-level await blocks the evaluation of the module graph the same way an awaited promise blocks an async function. Sibling modules in the import graph can still evaluate in parallel, but any module that depends on the awaiting module waits for it.
// timer.mjs
console.log("start");
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("after 1s");
export const ready = true;
// main.mjs
console.log("importing timer...");
import { ready } from "./timer.mjs";
console.log("timer ready:", ready);
Output:
importing timer...
start
after 1s
timer ready: true
Note that import statements are hoisted, so timer.mjs evaluates — and its top-level await completes — before the body of main.mjs runs, regardless of source order.
Top-level await can delay your application’s startup. If the awaited promise hangs, the entire module graph hangs with it. Always set timeouts or use
AbortControllerfor any network call you await at module scope.
Use cases
Dynamic configuration
Load settings from a file, environment, or remote service before the rest of the app initializes.
import { readFile } from "node:fs/promises";
const raw = await readFile(new URL("./settings.json", import.meta.url), "utf8");
export const settings = JSON.parse(raw);
Conditional and dynamic imports
Choose which implementation to load at runtime, then await the dynamic import().
const isProd = process.env.NODE_ENV === "production";
const { default: logger } = isProd
? await import("./logger.prod.mjs")
: await import("./logger.dev.mjs");
logger.info("Logger initialized");
Resource initialization with fallback
Top-level await pairs naturally with try/catch to degrade gracefully when a dependency is unavailable.
let db;
try {
const { connect } = await import("./db.mjs");
db = await connect(process.env.DATABASE_URL);
} catch (err) {
console.warn("DB unavailable, using in-memory store:", err.message);
const { createMemoryStore } = await import("./memory-store.mjs");
db = createMemoryStore();
}
export { db };
Limitations in CommonJS
Top-level await is an ES modules feature only — it has no equivalent in CommonJS (require). A CommonJS file cannot use await at the top level, and require() is synchronous, so it cannot load an ES module that uses top-level await.
| Concern | ES modules (import) | CommonJS (require) |
|---|---|---|
Top-level await | Supported | Not supported — SyntaxError |
| Importing an async module | Transparently waits | Cannot require() it |
| Loading an ESM dependency | import / await import() | Use await import() inside an async function |
If you are stuck in CommonJS, the workaround is the dynamic import() expression, which returns a promise and works in both module systems:
// legacy.cjs (CommonJS)
async function main() {
const config = (await import("./config.mjs")).default;
console.log("Loaded config:", config);
}
main().catch(console.error);
Output:
Loaded config: { serviceUrl: 'https://api.example.com/v2' }
Trying to
require()a module that uses top-level await throwsERR_REQUIRE_ASYNC_MODULEon older runtimes. Newer Node.js versions canrequire()synchronous ESM, but a module with top-level await is inherently asynchronous and still cannot be required.
Best practices
- Reserve top-level await for genuine startup work — config, connections, feature detection — not for per-request logic that belongs in functions.
- Always bound awaited network or I/O calls with a timeout or
AbortControllerso a stalled dependency cannot freeze the whole module graph. - Wrap fallible initialization in
try/catchand provide a sensible fallback rather than crashing the process on import. - Keep awaited work minimal; long delays at module scope directly increase your application’s cold-start time.
- Prefer ES modules (
"type": "module") for new projects so top-level await is available everywhere. - In CommonJS code, use
await import()inside an async function instead of reaching for unsupported top-level await.