ESC

Type to search the knowledge base.

Top-level await

Await at module top level — parent modules wait, graphs block carefully, and when to prefer async functions instead.

intermediate3 min read
  • javascript
  • top-level

ES modules can use await at the top level — no wrapping async function main(). That makes config loading and dynamic imports cleaner, and it changes when dependent modules evaluate.

// config.js
const res = await fetch('/config.json');
export const config = await res.json();

// app.js
import { config } from './config.js';
console.log(config.apiBase);
// app.js body does not run until config.js finishes its awaits

Only in modules

// classic script → SyntaxError
// <script type="module"> or .mjs / bundler ESM output required

Node: ESM ("type": "module" or .mjs). CommonJS files cannot top-level await.

Module evaluation order

When module A imports B and B has top-level await:

  1. B starts loading/executing.
  2. Hits await → B’s evaluation pauses.
  3. A waits because its import isn’t finished.
  4. When B’s promise settles, B finishes, then A continues.
// a.js
console.log('a start');
import './b.js';
console.log('a end');

// b.js
console.log('b start');
await new Promise((r) => setTimeout(r, 100));
console.log('b end');

// b start → (100ms) → b end → a start? actually import is hoisted:
// Execution: load graph, execute b first (dependency), pause on await,
// then complete b, then execute a.

Mental model: imports are dependencies; TLA makes a module’s “ready” promise-like.

Sibling imports

If app.js imports both slow.js (TLA) and fast.js, fast.js can still evaluate while slow.js is waiting — the graph is concurrent where possible. Don’t assume total global pause of the whole app.

Error handling

let config;
try {
  config = await loadConfig();
} catch (e) {
  config = defaults;
  console.error(e);
}
export { config };

Uncaught rejection during module evaluation fails the module (and importers). Catch near the boundary.

When not to use TLA

Prefer TLA Prefer async function / lazy
Small critical config for boot Optional features
Feature detection once Per-route data
Dynamic import() of polyfill then continue Large chains that block first paint

Blocking the entire entry module on a slow network fetch delays every importer. Sometimes:

export const configPromise = fetch('/config.json').then((r) => r.json());
// importers await configPromise when needed

Bundlers and browsers

Modern bundlers support TLA with caveats (code-splitting edges, async chunks). Verify output: some older pipelines wrap modules so TLA becomes async IIFEs with different timing.

Interview answer (out loud)

“Top-level await works in ES modules so a module can pause evaluation until a promise settles; importers wait for that module to finish. It’s great for boot config and ordered setup. I avoid blocking the whole app on optional network work and I catch errors so module load doesn’t fail open.”

Dynamic import + TLA

const mod = await import(`./locales/${locale}.js`);
export const messages = mod.default;

Powerful for locale packs; ensure locale is allowlisted to avoid bundling surprises / path injection.

Circular dependencies

TLA can make circular module graphs harder to reason about because evaluation pauses mid-module. Prefer acyclic boot graphs: config → services → UI entry.

Further reading

Related guides