frontend / es features / 03_top_level_await_and_dynamic_import.md

Top-Level Await and Dynamic Import

5 min read source

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:

  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:

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:

  1. Module-level config / setup — fetch config, read environment, init a singleton.
  2. Dynamic module loadingconst 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:

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 splittingReact.lazy(() => import("./HeavyComponent")).
  • Conditional loadingif (browserSupports) await import("./polyfill").
  • Tooling / scriptsconst { 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 .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 ../15_performance/05_lazy_loading.md).
  • 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.

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

Further reading