await directly in module scope, no async wrapper needed
Top-level await was one of the most requested features for JavaScript modules: waiting for asynchronous initialization directly in module scope, without wrapping the entire module in an async function. Few developers understand its effect on the module graph and the potential for deadlocks, this guide explains both.
Table of Contents
- 1. The problem before top-level await
- 2. How top-level await affects the module graph
- 3. Legitimate use cases: configuration, database, feature detection
- 4. Combining top-level await with dynamic import
- 5. Performance pitfalls: sequential vs. parallel awaits
- 6. Top-level await in Node.js ESM
- 7. Comparison: async IIFE vs. top-level await
- 8. Errors, deadlocks, and what CJS imports have against it
- 9. Bundler support: Vite, Webpack, and esbuild
- 10. Summary
- 11. FAQ
1. The problem before top-level await
Before top-level await was introduced, there was no way in JavaScript modules to wait for asynchronous operations directly in module scope. The classic problem: a module wants to establish a database connection, read a configuration file, or fetch an API key from a secret manager while loading. All of that is asynchronous, but an ES module has no async context and could not use await at the top level, at least before ES2022.
Three fragile patterns emerged as workarounds. First, the async IIFE (Immediately Invoked Function Expression), (async () => { await init(); })();, which appears to bypass module scope but cannot make exports wait. Second, callback-based initialization, which makes exports depend on an initialization flag and forces every consumer to check that flag. Third, exporting promises instead of the values themselves, which forces every consumer to use await in turn. Top-level await makes all three workarounds unnecessary.
// BEFORE top-level await: three fragile workarounds
// 1. async IIFE (exports are not ready when module is imported)
let db;
(async () => {
db = await connectDatabase(); // consumers might use db before it's set!
})();
export { db }; // undefined at import time, race condition
// 2. Export a promise (forces every consumer to await)
export const dbPromise = connectDatabase();
// Consumer: const db = await dbPromise; leaks into every module
// 3. Lazy init with flag (complex and error-prone)
let _db = null;
export async function getDb() {
if (!_db) _db = await connectDatabase();
return _db;
}
// Consumer: const db = await getDb(); repeated boilerplate everywhere
// AFTER top-level await (ES2022): clean and direct
const db = await connectDatabase(); // module waits here
export { db }; // guaranteed to be a real connection, not a promise
2. How top-level await affects the module graph
Top-level await is not a simple syntactic feature, it changes the loading order of the entire module graph. If module A points to module B and B contains a top-level await, then A waits for B to be fully loaded and initialized before A continues. That means: modules that depend on a module with top-level await get delayed, and that delay propagates through the entire dependency tree.
The JavaScript engine internally treats a module with top-level await like an async function: execution pauses at every await point, hands control back, and resumes once the promise is fulfilled. Sibling modules in the dependency graph can be loaded in parallel in the meantime, but every module that directly or transitively depends on the waiting module must wait too. That makes it important to understand that top-level await affects not just its own module, but everything that imports from it.
3. Legitimate use cases: configuration, database, feature detection
The most sensible use of top-level await is module initialization that depends on external asynchronous work. Three main use cases: first, loading configuration from an external source, environment variables from a secrets manager, feature flags from a remote config service, or dynamically loaded locale files. With top-level await, the configuration module is guaranteed to contain valid values on import, not promises.
Second, feature detection for browser APIs: const supportsWebGPU = await navigator.gpu?.requestAdapter() !== null, here you must wait for the browser's asynchronous response before the module can decide which implementation it exports. Third, conditional dynamic import, the module loads different implementations depending on the detected platform or capability. All of this was only possible with workarounds before top-level await, workarounds that compromised either type safety or export reliability.
// config.js: load secrets at module init time
const response = await fetch("https://config.internal/api/settings");
const config = await response.json();
export const { apiKey, dbUrl, featureFlags } = config;
// Any module importing from config.js gets real values, not promises
// feature-detection.js: async browser capability check
const gpu = await navigator.gpu?.requestAdapter();
export const hasWebGPU = gpu !== null;
// Conditional implementation loading based on detected features
export const imageProcessor = hasWebGPU
? await import("./gpu-processor.js").then(m => m.default)
: await import("./cpu-processor.js").then(m => m.default);
// database.js: established connection before any export is available
import { createPool } from "pg";
const pool = createPool({ connectionString: process.env.DATABASE_URL });
await pool.query("SELECT 1"); // verify connection is alive
export { pool };
// Importing modules can use pool directly, it's always connected
4. Combining top-level await with dynamic import
Combining top-level await with dynamic import is especially powerful for code-splitting scenarios that depend on asynchronous decisions. Dynamic import (import()) always returns a promise, before top-level await, that promise had to be resolved inside an async function. You could not wait directly in module scope. With top-level await, const module = await import("./heavy-module.js") is possible directly at the top level.
Especially interesting is the pattern of conditional implementation selection: a module decides at load time which implementation it provides, and loads the right version. This is the cleaner replacement for polyfill-loading patterns that used to be implemented either synchronously (and thus blocking) or via IIFE (and thus unsafe regarding timing). Top-level await makes this pattern declarative and type-safe, the exported interface is always the loaded implementation, never a promise of it.
5. Performance pitfalls: sequential vs. parallel awaits
The most serious mistake when using top-level await is awaiting independent operations sequentially. Writing two await expressions one after another executes them in sequence, even if they are completely independent of each other. If loading configuration takes 200ms and connecting to the database takes 300ms, module loading takes 500ms instead of the possible 300ms. That is a direct loading-time loss, and it hurts especially on the critical path of application startup.
The solution is Promise.all() for independent operations: const [config, db] = await Promise.all([loadConfig(), connectDb()]). This is the same technique used in async functions, but it is especially important in top-level position because the module blocks the entire downstream module graph. Every unnecessary sequential millisecond in the top-level await of a core module propagates as a delay to every importing module. The rule of thumb: for independent promises, always use Promise.all(), never sequential await lines.
// WRONG: sequential awaits block for 200ms + 300ms = 500ms
const config = await loadConfig(); // 200ms
const db = await connectDatabase(); // 300ms, starts AFTER config done
// Total: 500ms, unnecessarily slow
// RIGHT: parallel with Promise.all, only 300ms (max of both)
const [config, db] = await Promise.all([
loadConfig(), // 200ms, runs in parallel
connectDatabase(), // 300ms, runs in parallel
]);
// Total: 300ms, optimal
// RIGHT: Promise.allSettled when partial failure is acceptable
const [configResult, flagsResult] = await Promise.allSettled([
loadConfig(),
loadFeatureFlags(),
]);
const config = configResult.status === "fulfilled"
? configResult.value
: DEFAULT_CONFIG;
// RIGHT: race for timeout handling
const configWithTimeout = await Promise.race([
loadConfig(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Config load timeout")), 3000)
),
]);
6. Top-level await in Node.js ESM
Top-level await has been available in Node.js for ES modules since version 14.8, but only in .mjs files or in projects with "type": "module" in package.json. CommonJS modules (.cjs or .js without the module flag) do not support top-level await. This is a fundamental limitation: CJS modules are synchronous, they cannot require() an ESM module with top-level await, because CJS cannot wait for asynchronous module initialization.
For Node.js server applications this is an important architectural point: anyone who wants to use top-level await for database connections and configuration loading must commit to ESM. Migrating existing Node.js projects from CJS to ESM is possible incrementally, but top-level await is a strong argument for taking that step. The practical benefit is considerable: server bootstrap logic that used to live in nested then() chains or a bootstrap async function can be written linearly and readably at the top level of the entry point.
7. Comparison: async IIFE vs. top-level await
The async IIFE was the established workaround before top-level await, and it is still found in many codebases. The fundamental difference: an async IIFE starts the asynchronous work, but the module continues its own initialization immediately, so if it has exports, they may not be ready yet when the module is imported. Top-level await pauses the entire module evaluation until the promise is fulfilled, exports are guaranteed to be ready when the module is imported.
| Aspect | async IIFE | Top-Level Await | Consequence |
|---|---|---|---|
| Export guarantee | None, race condition | Guaranteed ready | No undefined on import |
| Error handling | Unhandled promise | Module load error | Error visible in the import stack |
| Module graph semantics | No synchronization | Graph waits correctly | Dependencies correctly serialized |
| CJS compatibility | Usable everywhere | ESM only | ESM migration required |
| Readability | Nested, indirect | Linear, direct | Less boilerplate |
Error handling is another important difference: if an async IIFE throws an error, it results in an unhandled promise rejection, which is handled differently depending on the runtime. If a top-level await fails, it propagates as a module load error, all importers of the module receive the same error, which propagates through the module graph up to the entry point. That makes the error more visible and easier to localize.
8. Errors, deadlocks, and what CJS imports have against it
A critical pitfall of top-level await is circular dependencies combined with top-level awaits. If module A imports module B and waits on a top-level await, while module B imports module A and also waits on a top-level await, a deadlock results: both modules wait on each other. JavaScript engines can detect such cycles and throw an error, but the error message is not always intuitive. The solution: avoid circular dependencies, especially in modules with top-level awaits.
CJS modules cannot import ESM modules with top-level await via require(), that is technically impossible, because require() is synchronous and CJS has no concept of asynchronous module loading. Anyone who needs to access an ESM module with top-level await from a CJS module must use import() (dynamic import, which returns a promise) and await the result inside an async function. This is one of the most important migration barriers when moving from CJS to ESM.
9. Bundler support: Vite, Webpack, and esbuild
Bundler support for top-level await is broad, but with limitations. Vite supports top-level await natively in development mode and in production builds for modern browser targets, with no extra configuration needed. Webpack 5 supports top-level await with experiments: { topLevelAwait: true } in the configuration and the asyncWebAssembly or outputModule experiment. esbuild supports top-level await for ESM output format, but not for CJS or IIFE, which is consistent with the semantics, since CJS has no concept of top-level await.
For browser bundles the rule is: the output format must be ES modules. Many build pipelines that still rely on IIFE or UMD output cannot support top-level await directly. That is another argument for switching to native ES modules as bundler output, support in modern browsers has been stable for years, and HTTP/2 makes the chunking model of native ES modules combined with lazy loading via dynamic import attractive.
10. Summary
Top-level await in ES modules solves a real problem: module initialization that depends on asynchronous work used to be possible only with error-prone workarounds. With top-level await, module evaluation pauses at every await point and resumes once the promise is fulfilled. Exports are guaranteed to be in their finished state when a module like this is imported, no race conditions, no undefined exports, no boilerplate.
The most important limitations: only available in ES modules (no CJS), circular dependencies with top-level await can lead to deadlocks, and sequential awaits for independent operations cost unnecessary time. The performance rule of thumb applies absolutely: always parallelize independent async operations with Promise.all(). Top-level await on the critical loading path should complete as fast as possible, because the entire dependent module hierarchy waits on it.
Mironsoft
JavaScript architecture, ESM migration, and performance optimization
Migrating from CJS to ESM and adopting top-level await?
We support the migration from CommonJS to ES modules, identify race conditions in initialization logic, and optimize the module graph for fast loading times.
ESM migration
CJS-to-ESM migration with an incremental rollout and full regression testing
Module architecture
Cycle analysis, dependency graph optimization, and a top-level await strategy
Build pipeline
Vite/Webpack configuration for top-level await, code splitting, and dynamic import
Top-Level Await, the essentials at a glance
ESM only, no CJS
Top-level await works only in ES modules. CJS is synchronous and cannot await module initialization with top-level await, require() is blocking.
Export guarantee
Exports of a module with top-level await are guaranteed to be in their finished state on import. No race condition like with async IIFE, and no undefined exports.
Promise.all() for parallelism
Never write sequential await lines for independent operations. Promise.all() parallelizes and cuts the loading time in half. Especially critical on the loading path.
Avoid cycles
Circular dependencies between modules with top-level await can lead to deadlocks. Design the dependency graph deliberately, no mutual imports with awaits.