| |

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:

ContextWorks?
ESM (.mjs)
Package with "type": "module"
<script type="module">
CommonJS (.cjs or default)
Classic <script>
Function bodyOnly 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:

  1. Fetch all dependencies (parallel)
  2. Evaluate dependencies in order
  3. Evaluate the current module

With top-level await:

The evaluation pauses at each await:

  1. Fetch all dependencies
  2. Evaluate dependencies in order
  3. If a dependency has top-level await, wait for it
  4. 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:

  1. main.js starts, imports users.js
  2. users.js imports db.js
  3. db.js awaits connect()
  4. Once connected, db.js exports the connection
  5. users.js awaits db.query(...)
  6. Once the query completes, users.js exports users
  7. main.js receives users and 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:

  1. Keep top-level await short — few statements, fast operations
  2. Parallelize independent awaits — use Promise.all
  3. Handle errors — wrap in try/catch or provide fallbacks
  4. Avoid in libraries — consumers pay the cost
  5. Prefer lazy loading when the value isn’t needed immediately
  6. 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

ContextWorks?
.mjs file
"type": "module" project
<script type="module">
CommonJS
Classic <script>
Function bodyOnly in async
Class body

Enabling ESM

EnvironmentHow
Node (.mjs)File extension
Node (project)"type": "module"
Browser<script type="module">
BundlersUsually automatic

Basic Usage

CodePurpose
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

FormBehavior
await f()Rejects propagate
try { await f() } catchLocal handling
Dynamic importRejects import()
Static import of failing moduleProgram fails

Module Ordering

StepWhat Happens
1Fetch dependencies in parallel
2Evaluate in dependency order
3Wait for top-level await
4Continue importer

Common Patterns

PatternUse
Config loadStartup config
DB connectShared resource
Parallel setupMultiple dependencies
Conditional importPolyfills, env-specific
Lazy cacheDefer until needed

Alternatives

WhenInstead
Value not needed at importasync function export
Slow operationLazy cache
LibraryExport a function
CyclesRefactor

Node.js

CommandNotes
node app.mjsESM file
node --input-type=moduleEval as ESM
REPLSupports top-level await

Bundler Targets

TargetTLA 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

PitfallProblemSolution
CommonJSawait syntax errorUse ESM
Classic scriptDoesn’t worktype="module"
Slow moduleBlocks importersLazy or cache
No error handlingProgram failstry/catch
Serial awaitsSlowPromise.all
CyclesDeadlockRefactor
LibraryConsumer slowsExport function
Wrong bundler targetBuild failses2022

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

ConceptSyntaxPurpose
Basicconst x = await f()Await at module top
Parallelawait Promise.all([...])Concurrent waits
Dynamicawait import('./m.js')Runtime import
Errortry { await f() } catch {}Local handling
ESM only.mjs or "type": "module"Required
Bundlertarget: 'es2022'Enable TLA
LazyExport functionDefer load
Cachecached ??= f()Load once

Key takeaways:

  • Top-level await lets you use await at 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.all for 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 es2022 or 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!