Using Top-Level Await in Modern JavaScript

If you’ve ever wrapped asynchronous code in an immediately invoked function expression (IIFE) just to use await
at the module level, you’re not alone. Before ES2022, JavaScript developers had to jump through hoops to handle async operations during module initialization. Top-level await JavaScript changes this by allowing await
directly in ES modules without an async
function wrapper.
This article explains how top-level await transforms module execution, its practical applications for configuration loading and dynamic imports, and the critical trade-offs you need to understand—including execution blocking and circular dependency pitfalls. You’ll learn when to use this powerful feature and, equally important, when to avoid it.
Key Takeaways
- Top-level await allows
await
directly in ES modules without wrapping in async functions - Module execution becomes asynchronous, blocking dependent modules until completion
- Best suited for one-time initialization, configuration loading, and conditional imports
- Avoid in libraries and utilities to prevent blocking downstream consumers
- Requires ES modules and modern runtime support (Node.js 14.8+, ES2022)
What Is Top-Level Await and Why Was It Introduced?
The Problem It Solves
Before top-level await, initializing a module with asynchronous data required workarounds:
// Old approach with IIFE
let config;
(async () => {
config = await fetch('/api/config').then(r => r.json());
})();
// config might be undefined when accessed!
This pattern created timing issues and made code harder to reason about. Modules couldn’t guarantee their async dependencies were ready before exporting values.
The ES2022 Solution
Top-level await allows await
expressions directly in module scope:
// Modern approach
const config = await fetch('/api/config').then(r => r.json());
export { config }; // Always defined when imported
This feature works exclusively in ES modules—files with .mjs
extension, or .js
files in projects with "type": "module"
in package.json. In browsers, scripts must use <script type="module">
.
How Top-Level Await Changes Module Execution
Module Loading Becomes Asynchronous
When JavaScript encounters await outside async function, it fundamentally changes how that module loads:
- Parsing Phase: The engine validates syntax and identifies imports/exports
- Instantiation Phase: Module bindings are created but not evaluated
- Evaluation Phase: Code executes, pausing at each
await
// database.js
console.log('1. Starting connection');
export const db = await connectDB();
console.log('2. Connection ready');
// app.js
console.log('3. App starting');
import { db } from './database.js';
console.log('4. Using database');
// Output order:
// 1. Starting connection
// 3. App starting
// 2. Connection ready
// 4. Using database
The Cascade Effect
Module dependencies create a chain reaction. When a module uses top-level await, every module that imports it—directly or indirectly—waits for completion:
// config.js
export const settings = await loadSettings();
// auth.js
import { settings } from './config.js';
export const apiKey = settings.apiKey;
// main.js
import { apiKey } from './auth.js'; // Waits for entire chain
Common Use Cases and Patterns
Dynamic Module Loading
Top-level await JavaScript excels at conditional imports based on runtime conditions:
// Load database driver based on environment
const dbModule = await import(
process.env.DB_TYPE === 'postgres'
? './drivers/postgres.js'
: './drivers/mysql.js'
);
export const db = new dbModule.Database();
Configuration and Resource Initialization
Perfect for loading configuration or initializing resources before module execution:
// i18n.js
const locale = await detectUserLocale();
const translations = await import(`./locales/${locale}.js`);
export function t(key) {
return translations.default[key] || key;
}
WebAssembly Module Loading
Simplifies WASM initialization without wrapper functions:
// crypto.js
const wasmModule = await WebAssembly.instantiateStreaming(
fetch('/crypto.wasm')
);
export const { encrypt, decrypt } = wasmModule.instance.exports;
Discover how at OpenReplay.com.
Critical Limitations and Trade-offs
ES Modules Only
Top-level await has strict context requirements:
// ❌ CommonJS - SyntaxError
const data = await fetchData();
// ❌ Classic script - SyntaxError
<script>
const data = await fetchData();
</script>
// ✅ ES Module
<script type="module">
const data = await fetchData();
</script>
Execution Blocking
Every await
creates a synchronization point that can impact application startup:
// slow-module.js
export const data = await fetch('/slow-endpoint'); // 5 second delay
// app.js
import { data } from './slow-module.js';
// Entire app waits 5 seconds before this line runs
Circular Dependency Deadlocks
Top-level await makes circular dependencies more dangerous:
// user.js
import { getPermissions } from './permissions.js';
export const user = await fetchUser();
// permissions.js
import { user } from './user.js';
export const permissions = await getPermissions(user.id);
// Result: Deadlock - modules wait for each other indefinitely
Best Practices for Production Use
When to Use Top-Level Await
- One-time initialization: Database connections, API clients
- Configuration loading: Environment-specific settings
- Feature detection: Loading polyfills conditionally
When to Avoid It
- Library modules: Never block downstream consumers
- Frequently imported utilities: Keep synchronous for performance
- Modules with circular dependency risk: Use async functions instead
Error Handling Strategies
Always handle failures to prevent module loading crashes:
// Safe pattern with fallback
export const config = await loadConfig().catch(err => {
console.error('Config load failed:', err);
return { defaultSettings: true };
});
// Alternative: Let consumer handle errors
export async function getConfig() {
return await loadConfig();
}
Build Tool and Runtime Support
Modern tooling handles top-level await JavaScript with varying approaches:
- Webpack 5+: Supports with
experiments.topLevelAwait
- Vite: Native support in development and production
- Node.js 14.8+: Full support in ES modules
- TypeScript 3.8+: Requires
module: "es2022"
or higher
For legacy environments, consider wrapping async logic in exported functions rather than using top-level await.
Conclusion
Top-level await transforms how we write asynchronous module initialization in JavaScript, eliminating IIFE workarounds and making code more readable. However, its power comes with responsibility—blocking module execution and potential circular dependency issues require careful consideration.
Use top-level await for application-specific initialization and configuration loading, but keep it out of shared libraries and utilities. By understanding both its capabilities and constraints, you can leverage this feature to write cleaner, more maintainable JavaScript modules while avoiding the pitfalls that come with pausing module execution.
FAQs
No, top-level await only works in ES modules. In Node.js, use .mjs files or set type module in package.json. CommonJS modules must continue using async functions or IIFEs for asynchronous operations.
Top-level await itself doesn't prevent tree shaking, but it can affect bundle splitting. Bundlers may group modules with top-level await differently to maintain execution order, potentially creating larger chunks.
Most modern test runners support ES modules with top-level await. For Jest, enable experimental ESM support. Consider mocking async dependencies or wrapping initialization in functions for easier testing.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.