Back

Using Top-Level Await in Modern JavaScript

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:

  1. Parsing Phase: The engine validates syntax and identifies imports/exports
  2. Instantiation Phase: Module bindings are created but not evaluated
  3. 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;

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.

OpenReplay