Frontend / ECMAScript features / 03_top_level_await_and_dynamic_import.md

Top-Level Await and Dynamic Import

Updated 4 interview angles 6 min read source
On this page14
  1. Top-level await — what does it do?
  2. Why is top-level await useful?
  3. When does top-level await hurt?
  4. Dynamic import() — show the syntax.
  5. When to use dynamic import?
  6. Dynamic import vs require()?
  7. Loading JSON dynamically — what about?
  8. Top-level await + Server Components — how do they interact?
  9. Promise.withResolvers — modern manual Promise.
  10. Why doesn’t top-level await work in CJS?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

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:

ts
// config.ts (ESM)
const response = await fetch("/config.json");
export const config = await response.json();

The module is treated as asynchronous:

  1. Engine parses + starts executing.
  2. Hits await — pauses.
  3. Resolves when the promise completes.
  4. Continues to subsequent statements.
  5. exports are available after the module completes.

Importers await the module’s completion:

ts
import { config } from "./config";
// works — module already resolved by import time
console.log(config.apiUrl);

Why is top-level await useful?

Three concrete cases:

  1. Module-level config / setup — fetch config, read environment, init a singleton.
  2. Dynamic module loading — const helper = await import(./helpers/${env}).
  3. Server initialization — connect to DB, register handlers, all before serving.

Without top-level await, the workaround was an IIFE:

ts
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>:

ts
// 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.
ts
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):

ts
// 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):

tsx
// 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:

ts
// 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 .js files; otherwise use .mjs.
  • TS module setting: "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 nodenext vs --module nodenext affects how dynamic import is compiled; mismatches cause runtime errors in Node.
  • Top-level await blocking the module graph — if A awaits, B imports A, C imports B, all of them wait. Slow A cascades.

Further reading

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 older assert { ... } 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 its resolve and reject. It replaces the deferred pattern of declaring two variables and assigning them inside the executor.