JavaScript 72 🧬 Top-level await
Before ES2022, await only worked inside async functions. To use it at the module’s top level, you had to wrap everything in an async IIFE — a workaround that added noise to every file that needed to fetch config, load a database connection, or initialize resources before exporting anything.
Top-level await changes that. In an ES module, you can await directly at the top of the file — no wrapper, no function, no indentation. The module simply doesn’t finish evaluating until the awaited Promise settles, and anything that imports it waits too.
Key point: Top-level await only works in ES modules — .mjs files, files in a package with "type": "module", or <script type="module"> in the browser. It doesn’t work in CommonJS or classic scripts.
a – What is top-level await
Top-level await lets you use the await keyword at the outermost scope of a module. The module evaluation pauses until the Promise resolves, and importing modules wait for it to complete.
The old way — async IIFE:
// config.js (before top-level await)
let config;
(async () => {
const res = await fetch('/config.json');
config = await res.json();
})();
export { config };
The IIFE starts an async operation but doesn’t block the module from finishing. That means config might still be undefined when another module imports it. This is the “async init race” — a common bug.
The new way — top-level await:
// config.js (with top-level await)
const res = await fetch('/config.json');
export const config = await res.json();
The module waits for the fetch before it finishes. When another module imports config, the value is guaranteed to be ready. No race, no wrapper, no indentation.
Why it matters:
- Guaranteed initialization — imports get fully-initialized values
- Cleaner code — no wrapper function
- Real module semantics — the module’s completion is meaningful
- Dynamic config — load resources before exporting
What you can await:
Any expression that returns a Promise:
const config = await fetch('/config').then(r => r.json());
const data = await import('./data.js');
const db = await connectDatabase();
const env = await loadEnvironment();
You can also await plain values — they resolve instantly:
const x = await 42;
Top-level await in different contexts:
| Context | Works? |
|---|---|
ESM (.mjs) | ✅ |
Package with "type": "module" | ✅ |
<script type="module"> | ✅ |
CommonJS (.cjs or default) | ❌ |
Classic <script> | ❌ |
| Function body | Only inside async |
| Class body | ❌ |
Enabling ESM:
In Node.js, either name the file .mjs or set "type": "module" in package.json:
{
"type": "module"
}
In the browser, use <script type="module">:
<script type="module" src="main.js"></script>
Basic example:
// data.js
const res = await fetch('https://api.example.com/users');
export const users = await res.json();
// main.js
import { users } from './data.js';
console.log(users); // guaranteed to be loaded
When main.js imports data.js, it waits until users is populated. The console.log always sees the loaded value.
Why the ordering matters:
Module evaluation is deterministic. If module A imports B, and B uses top-level await, A waits for B before it continues:
main.js
└── imports data.js
└── top-level await (fetch)
└── export set
└── continues
The dependency graph flows top-down, and top-level await makes the timing explicit.
When to use top-level await:
- Loading config at startup
- Setting up database connections
- Fetching initial data before exporting
- Dynamic imports based on environment
- Initializing WASM or native modules
- Any async setup that must complete before the module is usable
When NOT to use it:
- In library code that others import — slow the importer down
- When the work can be lazy (do it on first use, not at import)
- In modules that need to be fast to load
- When you don’t need the value immediately
The rule of thumb: use top-level await when the module’s exports depend on async data. Otherwise, export a function that does the async work on demand.
b – Module graph and ordering
Top-level await changes how module evaluation works. Understanding the ordering prevents surprises.
Synchronous modules:
Without top-level await, module evaluation is synchronous after all imports are resolved:
- Fetch all dependencies (parallel)
- Evaluate dependencies in order
- Evaluate the current module
With top-level await:
The evaluation pauses at each await:
- Fetch all dependencies
- Evaluate dependencies in order
- If a dependency has top-level await, wait for it
- Continue evaluating the current module
An example graph:
// db.js
const connection = await connect();
export default connection;
// users.js
import db from './db.js';
export const users = await db.query('SELECT * FROM users');
// main.js
import { users } from './users.js';
console.log(users);
Evaluation order:
main.jsstarts, importsusers.jsusers.jsimportsdb.jsdb.jsawaitsconnect()- Once connected,
db.jsexports the connection users.jsawaitsdb.query(...)- Once the query completes,
users.jsexportsusers main.jsreceivesusersand continues
Each module waits for its dependencies. The chain is guaranteed.
Parallel fetching, sequential evaluation:
The browser (or Node) fetches all modules in parallel, but evaluates them in dependency order. This means slow modules block their importers, but not their siblings.
main.js
/ \
users.js posts.js
\ /
db.js
│
└── top-level await connect()
Both users.js and posts.js wait for db.js to finish. They run in parallel after that.
Importing a slow module:
// slow.js
await new Promise(r => setTimeout(r, 5000));
export const data = 'ready';
// main.js
import { data } from './slow.js';
console.log(data);
// Logs after 5 seconds
The import blocks the entire main module for 5 seconds. This is the biggest risk with top-level await in library code.
Circular dependencies with top-level await:
Top-level await can make circular dependencies deadlock if not careful:
// a.js
import { b } from './b.js';
export const a = await something(b);
// b.js
import { a } from './a.js';
export const b = await something(a);
Neither module can complete because each waits for the other. The engine may throw a ReferenceError when accessing the uninitialized binding, or hang depending on the case.
Avoid circular dependencies — they’re fragile in any module system, and top-level await makes them worse.
Error handling in top-level await:
If an awaited Promise rejects, the module evaluation fails. Importers see the error as a rejected import:
// failing.js
const data = await fetch('/missing').then(r => {
if (!r.ok) throw new Error('Not found');
return r.json();
});
// main.js
try {
const mod = await import('./failing.js');
} catch (err) {
console.error('Import failed:', err.message);
}
The dynamic import() rejects, and you can catch it. Static imports (import x from './failing.js') will throw at parse time, and the whole program fails to start.
Handling errors at the top level:
let config;
try {
config = await fetch('/config').then(r => r.json());
} catch {
config = { fallback: true };
}
export { config };
Wrap the await in try/catch to provide fallbacks. This is essential for network-dependent initialization.
Blocking on the network:
// ❌ Blocking main
const config = await fetch('/config').then(r => r.json());
export default config;
If the network is slow, the entire app waits. Consider whether the value is truly needed at import time.
Lazy alternative:
// ✅ Lazy — config loaded on first use
let cached;
export async function getConfig() {
return cached ??= await fetch('/config').then(r => r.json());
}
The consumer decides when to load. No blocking, more control.
Top-level await and Promise.all:
You can await multiple Promises in parallel:
const [users, posts, comments] = await Promise.all([
fetch('/users').then(r => r.json()),
fetch('/posts').then(r => r.json()),
fetch('/comments').then(r => r.json())
]);
export { users, posts, comments };
All three fetches run in parallel; the module waits for all of them. This is faster than sequential awaits.
Sequential vs parallel:
// Sequential — slow
const users = await fetch('/users').then(r => r.json());
const posts = await fetch('/posts').then(r => r.json());
// Total: users + posts
// Parallel — fast
const [users, posts] = await Promise.all([
fetch('/users').then(r => r.json()),
fetch('/posts').then(r => r.json())
]);
// Total: max(users, posts)
Same for top-level await — parallelize independent operations.
The deadlock risk:
Circular dependencies with top-level await can deadlock the module graph. The spec allows it in many cases, but real engines may throw:
// a.js
import './b.js';
export const a = await Promise.resolve('a');
// b.js
import './a.js';
export const b = await Promise.resolve('b');
The exact behavior depends on the engine and the shape of the cycle. Avoid cycles entirely.
Top-level await and thenable values:
Anything with a .then method is awaited. This includes Promises, but also objects that behave like them. Be careful — a value with an unintended .then could hang the module.
Best practices for ordering:
- Keep top-level await short — few statements, fast operations
- Parallelize independent awaits — use
Promise.all - Handle errors — wrap in
try/catchor provide fallbacks - Avoid in libraries — consumers pay the cost
- Prefer lazy loading when the value isn’t needed immediately
- Watch for cycles — they can deadlock with top-level await
c – Practical uses
Top-level await is useful in a handful of specific scenarios. These are the ones worth adopting.
Dynamic config loading:
// config.js
const env = process.env.NODE_ENV ?? 'development';
const res = await fetch(`/config.${env}.json`);
export const config = await res.json();
The module loads the right config file based on the environment. Every importer gets the config without a separate initialization step.
Database connection at startup:
// db.js
import { createPool } from 'pg';
const pool = createPool({
connectionString: process.env.DATABASE_URL
});
await pool.connect();
export default pool;
The pool is guaranteed connected before any module uses it. No “is the DB ready?” checks in every route handler.
Conditional imports:
// polyfill.js
if (!globalThis.fetch) {
await import('node-fetch').then(m => {
globalThis.fetch = m.default;
});
}
The module loads a polyfill only when needed. This works because import() is dynamic and awaited at the top level.
Internationalization:
// i18n.js
const lang = navigator.language ?? 'en';
const messages = await fetch(`/i18n/${lang}.json`).then(r => r.json());
export default messages;
The right language is loaded before the app starts.
WASM initialization:
// wasm.js
const response = await fetch('/module.wasm');
const buffer = await response.arrayBuffer();
const module = await WebAssembly.instantiate(buffer);
export const wasm = module.instance.exports;
WebAssembly modules need async loading — top-level await makes it clean.
Feature flags from a server:
// flags.js
const userId = getCurrentUserId();
const res = await fetch(`/flags?user=${userId}`);
export const flags = await res.json();
Flags loaded once, available everywhere.
Secret management:
// secrets.js
const response = await fetch('/secrets');
export const secrets = await response.json();
In serverless or edge environments, secrets often come from a network call. Top-level await makes them available before the module is used.
Node.js startup:
// server.js
import { setup } from './setup.js';
import { createServer } from './server.js';
await setup();
const app = await createServer();
app.listen(3000);
In Node, top-level await lets you sequence startup without an async wrapper. The file runs top to bottom.
Environment-aware module loading:
// analytics.js
export const analytics = process.env.NODE_ENV === 'production'
? await import('./analytics.prod.js')
: await import('./analytics.dev.js');
Different implementations for different environments — resolved at module load.
Reading a file at startup (Node):
// config.js
import { readFile } from 'fs/promises';
const raw = await readFile('./config.json', 'utf8');
export default JSON.parse(raw);
The config is loaded once when the module is imported. All consumers see the same value.
A combination example:
// app.js
const env = process.env.NODE_ENV;
const [config, db, flags] = await Promise.all([
loadConfig(env),
connectDatabase(),
fetchFeatureFlags()
]);
export { config, db, flags };
Three independent async operations start in parallel. The module waits for all of them. Consumers get everything ready.
What to avoid:
- Long-running operations — a 30-second fetch blocks everyone
- Optional data — if the module works without it, don’t block
- Frequently-imported modules — every import pays the cost
- Library code — consumers can’t opt out
- Cycles — potential deadlock
Alternatives to top-level await:
When the value isn’t needed at import time, prefer:
// Instead of top-level await
export async function loadConfig() {
return fetch('/config').then(r => r.json());
}
The consumer awaits loadConfig() when they need it. No blocking.
Or use a lazy cache:
let cached;
export async function getConfig() {
return cached ??= fetch('/config').then(r => r.json());
}
The first caller triggers the load; subsequent callers get the cached value.
Node.js REPL and eval:
Top-level await works in the Node REPL:
> await fetch('https://api.example.com').then(r => r.json())
{ ... }
But not in node -e "await ..." unless you pass --input-type=module.
Browsers and classic scripts:
Classic <script> tags don’t support top-level await. Only <script type="module">:
<!-- ❌ Doesn't work -->
<script>
const data = await fetch('/data');
</script>
<!-- ✅ Works -->
<script type="module">
const data = await fetch('/data');
</script>
Bundlers:
Modern bundlers — Vite, esbuild, Rollup — support top-level await. Some older configurations may need target adjustments:
// vite.config.js
export default {
build: {
target: 'es2022' // supports top-level await
}
};
If the target is older (like es2015), the bundler will either transform it or fail.
Dynamic import for lazy top-level await:
// lazy.js
let loaded;
export async function ensureLoaded() {
if (!loaded) {
const mod = await import('./heavy.js');
loaded = mod.default;
}
return loaded;
}
Only load the heavy module when needed. Avoids the top-level await cost while keeping the initialization lazy.
Complete Example Session
// ============================================
// PART 1: BASIC TOP-LEVEL AWAIT
// ============================================
// data.js
const res = await fetch('/api/users');
export const users = await res.json();
// main.js
import { users } from './data.js';
console.log(users);
// [ [ { id: 1, name: 'Alice' }, ... ] ]
// ============================================
// PART 2: OLD WAY — ASYNC IIFE
// ============================================
// config.js (before)
let config;
(async () => {
config = await fetch('/config').then(r => r.json());
})();
export { config };
// Consumer might see `undefined` if it imports too early
// ============================================
// PART 3: NEW WAY — TOP-LEVEL AWAIT
// ============================================
// config.js (after)
export const config = await fetch('/config').then(r => r.json());
// Consumer always sees the loaded value
// ============================================
// PART 4: PARALLEL AWAITS
// ============================================
const [users, posts] = await Promise.all([
fetch('/users').then(r => r.json()),
fetch('/posts').then(r => r.json())
]);
export { users, posts };
// ============================================
// PART 5: ERROR HANDLING
// ============================================
let config;
try {
config = await fetch('/config').then(r => r.json());
} catch {
config = { fallback: true };
}
export { config };
// ============================================
// PART 6: DATABASE CONNECTION
// ============================================
// db.js
import { createPool } from 'pg';
const pool = createPool({ connectionString: process.env.DATABASE_URL });
await pool.connect();
export default pool;
// ============================================
// PART 7: CONDITIONAL IMPORT
// ============================================
if (!globalThis.fetch) {
await import('node-fetch').then(m => {
globalThis.fetch = m.default;
});
}
export const fetchAvailable = true;
// ============================================
// PART 8: ENVIRONMENT AWARE
// ============================================
const env = process.env.NODE_ENV ?? 'development';
const res = await fetch(`/config.${env}.json`);
export const config = await res.json();
// ============================================
// PART 9: LAZY ALTERNATIVE
// ============================================
let cached;
export async function getConfig() {
return cached ??= fetch('/config').then(r => r.json());
}
// ============================================
// PART 10: NODE.JS SERVER STARTUP
// ============================================
// server.js
import { setup } from './setup.js';
import { createServer } from './server.js';
await setup();
const app = await createServer();
app.listen(3000);
console.log('Server started');
// ============================================
// PART 11: FILE READ (NODE)
// ============================================
// config.js
import { readFile } from 'fs/promises';
const raw = await readFile('./config.json', 'utf8');
export default JSON.parse(raw);
// ============================================
// PART 12: WASM LOAD
// ============================================
// wasm.js
const response = await fetch('/module.wasm');
const buffer = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(buffer);
export const wasm = instance.exports;
// ============================================
// PART 13: PARALLEL SETUP
// ============================================
// app.js
const [config, db, flags] = await Promise.all([
loadConfig(),
connectDatabase(),
fetchFeatureFlags()
]);
export { config, db, flags };
// ============================================
// PART 14: BUNDLER CONFIG
// ============================================
// vite.config.js
export default {
build: {
target: 'es2022'
}
};
// ============================================
// PART 15: DYNAMIC ERROR CATCH
// ============================================
try {
const mod = await import('./optional-feature.js');
console.log('Feature loaded');
} catch (err) {
console.log('Feature unavailable:', err.message);
}
Quick Reference
Where Top-Level Await Works
| Context | Works? |
|---|---|
.mjs file | ✅ |
"type": "module" project | ✅ |
<script type="module"> | ✅ |
| CommonJS | ❌ |
Classic <script> | ❌ |
| Function body | Only in async |
| Class body | ❌ |
Enabling ESM
| Environment | How |
|---|---|
Node (.mjs) | File extension |
| Node (project) | "type": "module" |
| Browser | <script type="module"> |
| Bundlers | Usually automatic |
Basic Usage
| Code | Purpose |
|---|---|
const x = await f() | Await result |
const [a, b] = await Promise.all([...]) | Parallel |
const mod = await import('./m.js') | Dynamic import |
await new Promise(r => setTimeout(r, 1000)) | Delay |
Error Handling
| Form | Behavior |
|---|---|
await f() | Rejects propagate |
try { await f() } catch | Local handling |
| Dynamic import | Rejects import() |
| Static import of failing module | Program fails |
Module Ordering
| Step | What Happens |
|---|---|
| 1 | Fetch dependencies in parallel |
| 2 | Evaluate in dependency order |
| 3 | Wait for top-level await |
| 4 | Continue importer |
Common Patterns
| Pattern | Use |
|---|---|
| Config load | Startup config |
| DB connect | Shared resource |
| Parallel setup | Multiple dependencies |
| Conditional import | Polyfills, env-specific |
| Lazy cache | Defer until needed |
Alternatives
| When | Instead |
|---|---|
| Value not needed at import | async function export |
| Slow operation | Lazy cache |
| Library | Export a function |
| Cycles | Refactor |
Node.js
| Command | Notes |
|---|---|
node app.mjs | ESM file |
node --input-type=module | Eval as ESM |
| REPL | Supports top-level await |
Bundler Targets
| Target | TLA Support |
|---|---|
es2015 | ❌ |
es2022 | ✅ |
esnext | ✅ |
Best Practices
✅ Do This:
// Use for config that must load
export const config = await fetch('/config').then(r => r.json()); // ✅
// Parallelize independent awaits
const [a, b] = await Promise.all([f(), g()]); // ✅
// Handle errors locally
let config;
try { config = await loadConfig(); } catch { config = {}; } // ✅
// Use dynamic import for optional features
const mod = await import('./optional.js'); // ✅
// Cache lazy loads
let cached;
export const get = () => cached ??= fetch('/data'); // ✅
// Keep modules fast when possible
// Prefer lazy export when the value isn't needed immediately // ✅
❌ Don’t Do This:
// Don't block on slow work in library code
export const data = await fetch('/slow').then(r => r.json()); // ❌
// Don't use in CommonJS
const data = await fetch('/x'); // ❌ CJS
// Don't create cycles with TLA
// a.js imports b.js, b.js imports a.js // ❌ deadlock
// Don't forget error handling
const data = await fetch('/maybe').then(r => r.json()); // ⚠️ rejects
// Don't serialize independent awaits
const a = await f();
const b = await g(); // ❌ use Promise.all
// Don't slow every importer
// Keep top-level await minimal // ✅
// Don't rely on it in classic scripts
<script> await fetch(...) </script> // ❌
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| CommonJS | await syntax error | Use ESM |
| Classic script | Doesn’t work | type="module" |
| Slow module | Blocks importers | Lazy or cache |
| No error handling | Program fails | try/catch |
| Serial awaits | Slow | Promise.all |
| Cycles | Deadlock | Refactor |
| Library | Consumer slows | Export function |
| Wrong bundler target | Build fails | es2022 |
Real-World Examples
1. Load config
export const config = await fetch('/config').then(r => r.json());
One-liner config module.
2. Parallel setup
const [a, b] = await Promise.all([loadA(), loadB()]);
export { a, b };
Independent loads run together.
3. Database connection
const pool = createPool({ connectionString: process.env.DB_URL });
await pool.connect();
export default pool;
Guaranteed connection.
4. Polyfill
if (!globalThis.fetch) {
await import('node-fetch').then(m => globalThis.fetch = m.default);
}
Conditional loading.
5. Environment config
const env = process.env.NODE_ENV;
export const config = await fetch(`/config.${env}.json`).then(r => r.json());
Different config per environment.
6. WASM init
const buf = await (await fetch('/m.wasm')).arrayBuffer();
const { instance } = await WebAssembly.instantiate(buf);
export const wasm = instance.exports;
Async WASM load.
7. Error fallback
let config;
try { config = await loadConfig(); }
catch { config = { fallback: true }; }
export { config };
Never let the module fail.
8. Lazy alternative
let cached;
export const getConfig = () => cached ??= loadConfig();
Export a function, not a value.
9. Node startup
await setup();
const app = await createApp();
app.listen(3000);
Server startup without wrapper.
10. Dynamic import error
try { const m = await import('./feature.js'); }
catch (err) { console.log('Unavailable'); }
Optional features loaded on demand.
Visual: Module Graph with Top-Level Await
┌──────────────────────────────────────────────┐
│ main.js │
│ │ │
│ ├──► config.js │
│ │ await fetch('/config') │
│ │ export config │
│ │ │
│ ├──► db.js │
│ │ await pool.connect() │
│ │ export pool │
│ │ │
│ └──► users.js │
│ await db.query(...) │
│ export users │
│ │
│ main.js waits for all three │
│ │
└──────────────────────────────────────────────┘
Visual: Before and After
┌──────────────────────────────────────────────┐
│ Before (async IIFE) │
│ │
│ let config; │
│ (async () => { │
│ config = await fetch('/config'); │
│ })(); │
│ export { config }; │
│ │
│ ⚠️ config may be undefined on import │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ After (top-level await) │
│ │
│ export const config = await fetch('/c'); │
│ │
│ ✅ config is guaranteed loaded │
│ │
└──────────────────────────────────────────────┘
Visual: Sequential vs Parallel
┌──────────────────────────────────────────────┐
│ Sequential │
│ │
│ const a = await fetchA(); ──►──►──► │
│ const b = await fetchB(); ──►──►──► │
│ │
│ Total: A + B │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Parallel │
│ │
│ const [a, b] = await Promise.all([ │
│ fetchA(), ──►──►──► │
│ fetchB() ──►──►──► │
│ ]); │
│ │
│ Total: max(A, B) │
│ │
└──────────────────────────────────────────────┘
Visual: Blocking vs Lazy
┌──────────────────────────────────────────────┐
│ Top-level await │
│ │
│ main.js │
│ │ │
│ └── import config.js │
│ │ │
│ └── await fetch (blocks) │
│ │
│ Everyone waits │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Lazy export │
│ │
│ main.js │
│ │ │
│ └── import config.js (returns instantly) │
│ │ │
│ └── await getConfig() (when needed) │
│ │
│ Only blocks when called │
│ │
└──────────────────────────────────────────────┘
Summary
| Concept | Syntax | Purpose |
|---|---|---|
| Basic | const x = await f() | Await at module top |
| Parallel | await Promise.all([...]) | Concurrent waits |
| Dynamic | await import('./m.js') | Runtime import |
| Error | try { await f() } catch {} | Local handling |
| ESM only | .mjs or "type": "module" | Required |
| Bundler | target: 'es2022' | Enable TLA |
| Lazy | Export function | Defer load |
| Cache | cached ??= f() | Load once |
Key takeaways:
- Top-level await lets you use
awaitat the outermost scope of an ES module - It guarantees initialization — importers wait for the module’s async work to complete
- Only works in ES modules —
.mjs,"type": "module", or<script type="module"> - Replaces the async IIFE wrapper that was previously required
- Use
Promise.allfor parallel awaits — sequential is slower - Handle errors with
try/catch— a rejected top-level await fails the entire module - Watch for cycles — circular dependencies with top-level await can deadlock
- Don’t block libraries — every importer pays the cost
- Prefer lazy exports (
export function getConfig()) when the value isn’t needed immediately - Cache with
cached ??= load()to load once on first use - Works in Node.js (
.mjs,"type": "module"), browsers (type="module"), and modern bundlers - Set bundler target to
es2022or higher - Perfect for config loading, database connections, WASM init, polyfills, and environment-specific imports
Remember: Top-level await makes async module initialization clean. Instead of an async IIFE that races, the module waits — and so does everything importing it. Use it when the module’s exports depend on async data. Avoid it in libraries or on slow operations. Always handle errors. Prefer lazy alternatives when the value isn’t needed at import time. And never create cycles — they can deadlock the module graph. Master top-level await, and your async module setup becomes as simple as a synchronous one.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!