Top-Level Await and Dynamic Import
Top-level await (ES2022) lets you await directly in an ES module body — no async wrapper IIFE needed. The module pauses loading until the promise resolves; importers wait too. Dynamic import() (ES2020) lets you load a module at runtime as a promise — the foundation for code splitting and conditional loading. Both are ESM-only; CJS doesn’t have them.
Top-level await — what does it do?
await works at the top level of an ES module:
// config.ts (ESM)
const response = await fetch("/config.json");
export const config = await response.json();The module is treated as asynchronous:
- Engine parses + starts executing.
- Hits
await— pauses. - Resolves when the promise completes.
- Continues to subsequent statements.
exports are available after the module completes.
Importers await the module’s completion:
import { config } from "./config";
// works — module already resolved by import time
console.log(config.apiUrl);Why is top-level await useful?
Three concrete cases:
- Module-level config / setup — fetch config, read environment, init a singleton.
- Dynamic module loading —
const helper = await import(./helpers/${env}). - Server initialization — connect to DB, register handlers, all before serving.
Without top-level await, the workaround was an IIFE:
let config;
(async () => { config = await loadConfig(); })();
// config is undefined until... whenever. Bug source.Top-level await makes the dependency real: importers can’t see the module before it’s ready.
When does top-level await hurt?
Two scenarios:
- Circular dependencies — if module A awaits while B (which A imports) awaits A, you get a deadlock that the engine detects and throws.
- Slow imports cascade — every importer of an awaiting module waits. A slow fetch in one module blocks the whole tree.
Use it deliberately. Don’t put top-level await in shared modules without considering the cascade.
Dynamic import() — show the syntax.
Returns a Promise<Module>:
// Static — bundled together
import { heavyThing } from "./heavy";
// Dynamic — separate chunk, loaded on demand
const { heavyThing } = await import("./heavy");The bundler sees the dynamic call and splits the chunk (see Code Splitting Mechanics — Dynamic Imports, Manual Chunks). At runtime, the module fetches lazily.
When to use dynamic import?
- Code splitting —
React.lazy(() => import("./HeavyComponent")). - Conditional loading —
if (browserSupports) await import("./polyfill"). - Tooling / scripts —
const { default: chalk } = await import("chalk")to load ESM-only deps from CJS code. - Plugin / extension loading — load a module name picked at runtime.
async function loadPlugin(name: string) {
const mod = await import(`./plugins/${name}.js`);
return mod.default;
}Note: runtime-computed paths can confuse bundlers — they may include all matching files in the bundle or fail to split. Static prefix + variable suffix is usually OK; full dynamic strings less so.
Dynamic import vs require()?
require() |
import() |
|
|---|---|---|
| Sync? | yes | no — returns Promise |
| Module type | CJS | ESM (or CJS via interop) |
| Available in | Node CJS | both Node + browser |
| Bundling | sometimes splits | always splits |
If you’re in a CJS file and need an ESM-only package, use await import(...), even though require() looks more natural — require will fail for ESM packages.
Loading JSON dynamically — what about?
Modern import attributes (Stage 4 — ES2025):
// Static import with attributes
import data from "./config.json" with { type: "json" };
// Dynamic
const mod = await import("./config.json", { with: { type: "json" } });
const data = mod.default;The with { type: "json" } is now required for JSON in Node 22+. Earlier assert { type: "json" } syntax is deprecated.
Bundlers (Vite, Webpack) accept with and pre-process the JSON.
Top-level await + Server Components — how do they interact?
In Next.js Server Components, you can await at the top of a component function (since RSC is server-only async-friendly):
// app/page.tsx (Server Component)
export default async function HomePage() {
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}In client components ("use client"), top-level await isn’t allowed inside the component body — use useEffect + state, <Suspense> + use(), or TanStack Query.
Promise.withResolvers — modern manual Promise.
ES2024 added a cleaner way to create a Promise + accessing its resolve/reject from outside:
// Old way — Deferred pattern
function deferred() {
let resolve!: (v: any) => void, reject!: (e: any) => void;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
// ES2024
const { promise, resolve, reject } = Promise.withResolvers();
setTimeout(() => resolve("done"), 1000);
await promise; // "done"Useful for bridging callback-style APIs to async/await and for “wait for some external event.”
Why doesn’t top-level await work in CJS?
CJS is synchronous by design — require() blocks until the module is loaded. There’s no place for await to suspend. ESM was designed as async (modules can have async dependencies), which makes top-level await semantically meaningful.
If you’re in a CJS file and want await, the workarounds: an async IIFE, or migrate to ESM ("type": "module" in package.json or rename to .mjs).
Gotchas
"type": "module"required for top-level await in.jsfiles; otherwise use.mjs.- TS
modulesetting:"module": "ESNext"or"NodeNext"to enable; with"module": "CommonJS", top-level await is a TS error. - Bundler support varies — Vite, Rollup, esbuild all handle top-level await. Webpack 5 supports with
experiments.topLevelAwait: true. - Older browsers — top-level await needs ESM, which needs
<script type="module">. Fine in evergreens; doesn’t work in IE 11. - Dynamic
import()chunk loading errors — network drops, deploy invalidated old hash. Wrap with retry (see Lazy Loading — Route, Component, Interaction). - TS
--module nodenextvs--module nodenextaffects how dynamic import is compiled; mismatches cause runtime errors in Node. - Top-level await blocking the module graph — if
Aawaits,B imports A,C imports B, all of them wait. SlowAcascades.
Related
Further reading
- MDN —
import(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import - v8 blog — Top-level await: https://v8.dev/features/top-level-await
- Node.js docs — ESM modules: https://nodejs.org/api/esm.html
- TC39 — Promise.withResolvers: https://github.com/tc39/proposal-promise-with-resolvers
Interview angle 4
- “What does top-level await actually do?” - it lets a module pause its own
evaluation until a promise settles, and every module importing it waits too.
ESM only - CommonJS has no equivalent, and the workaround there is an async
IIFE or migrating.
- Follow-up: “When is it dangerous?” - in a widely-imported module, because the wait cascades through the whole graph. Reading config at startup is fine; anything on a network at the top of a shared utility delays everything that touches it.
- “What is dynamic
import()for?” - loading a module on demand. It returns a promise, the bundler sees the call and emits a separate chunk, and it is therefore the mechanism behind code splitting, lazy routes and conditionally loading a heavy dependency. - “How do you import JSON in modern ESM?” -
import data from "./x.json" with { type: "json" }. The olderassert { ... }syntax is deprecated, so code using it is a version marker rather than just a style choice. - “How do you create a promise you resolve from elsewhere?” -
Promise.withResolvers()(ES2024), which hands back the promise plus itsresolveandreject. It replaces the deferred pattern of declaring two variables and assigning them inside the executor.