Skip to content
Node.js nd async 4 min read

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 AbortController for 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.

ConcernES modules (import)CommonJS (require)
Top-level awaitSupportedNot supported — SyntaxError
Importing an async moduleTransparently waitsCannot require() it
Loading an ESM dependencyimport / 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 throws ERR_REQUIRE_ASYNC_MODULE on older runtimes. Newer Node.js versions can require() 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 AbortController so a stalled dependency cannot freeze the whole module graph.
  • Wrap fallible initialization in try/catch and 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.
Last updated June 14, 2026
Was this helpful?