Top-Level Await and Dynamic Import
TL;DR
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.
Interview Q&A
Q: Top-level await — what does it do?
A: 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";
console.log(config.apiUrl); // works — module already resolved by import time
Q: Why is top-level await useful?
A: 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.
Q: When does top-level await hurt?
A: 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.
Q: Dynamic import() — show the syntax.
A: 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 ../09_build_tools/04_code_splitting_mechanics.md). At runtime, the module fetches lazily.
Q: When to use dynamic import?
A:
- 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.
Q: Dynamic import vs require()?
A:
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.
Q: Loading JSON dynamically — what about?
A: 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.
Q: Top-level await + Server Components — how do they interact?
A: 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.
Q: Promise.withResolvers — modern manual Promise.
A: 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.”
Q: Why doesn’t top-level await work in CJS?
A: 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 / edge cases
"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 ../15_performance/05_lazy_loading.md). - 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.
What a senior is expected to say
- “Top-level await is ESM-only, lets a module pause loading until a promise resolves. Importers wait too. Useful for config, init, server connection setup; risky in shared modules because the wait cascades.”
- “Dynamic
import()returns a Promise — the foundation for code splitting and conditional loading. Bundler sees the call and splits the chunk.” - “Both are ESM features. CJS has neither — workarounds are async IIFEs or migrating to ESM.”
- “
import data from './x.json' with { type: 'json' }is the modern JSON import.assert {}is deprecated.” - “
Promise.withResolvers(ES2024) replaces the deferred pattern — cleaner manual Promise creation.”
Cross-references
- Module formats (ESM/CJS): ../09_build_tools/03_module_formats.md
- Dynamic import for code splitting: ../09_build_tools/04_code_splitting_mechanics.md
- React Server Components (top-level await use case): ../05_react/server_components.md
- Promise extensions: 07_promise_extensions.md
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