Skip to solution
hardSystem Design

How does top-level await change how an ES module and its importers load, and what are the risks of using it carelessly?

260 views
01

Understand the problem

Question presented to candidate: "Normally, await only works inside an async function. Top-level await lets you use it directly in an ES module body. Walk me through what actually happens to that module, and to any other module that imports it, while it is awaiting — and then tell me what can go genuinely wrong if you reach for this carelessly."

What a strong answer should cover:

  • Top-level await lets a module's own body pause at an await without wrapping it in an async function — but the module does not evaluate in isolation: any module that statically imports it (directly or transitively) genuinely waits for that top-level promise to settle before ITS OWN body runs.
  • A precise answer separates two different things: an UNRELATED sibling module (one that does not depend on the slow one) can still evaluate independently while the slow module awaits — only modules actually on the dependent chain are genuinely blocked.
  • A subtle, easy-to-miss detail: import statements are hoisted for evaluation purposes — a module's own top-level code, even lines positioned textually BEFORE its import statements in source, genuinely does not run until every one of its static imports has finished evaluating.
  • A rejected top-level await inside a STATICALLY imported module is a genuinely serious failure mode: it can crash the whole module graph's evaluation uncatchably from the importer's perspective, unlike a rejection reached through a dynamic import(), which is a real, ordinary catchable promise rejection.
  • Circular imports combined with top-level await are a genuine, real risk — not just a theoretical one — capable of producing a real, live ReferenceError from accessing a binding before its module has finished initializing.
  • A precise answer names when top-level await stabilized without a flag: Node.js v14.8.0 (ES modules only — it does not work in CommonJS).

Clarifying questions expected:

  • "Is the slow operation at the top level something every consumer of this module genuinely needs before they can do anything (like a required config fetch), or could it be deferred to only where it is actually used?" — the real deciding factor for whether top-level await is the right tool versus a lazily-awaited function.
  • "Could this module ever end up in a circular import relationship with something else in the dependency graph?" — a real, concrete risk category worth ruling out early.

Code / implementation expected: Yes — real, timestamped evidence of an importer genuinely blocking on a slow module's top-level await, plus a real reproduction of the circular-import failure mode, is the concrete way to prove the mechanics rather than just describe them.

esmtop-level-awaitmodules
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for JavaScript module-system interviews. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every timestamp and error below is real, captured output — a live browser run, a real

Solution ready — 2 min read

Classified // press E to declassify

04

Run the code

JSA real, runnable browser demo: a Blob-backed ES module with a genuine top-level await, imported dynamically (matches this doc's live-verified timestamps)
Reference: the real Node ESM files that produced this doc's ordering and crash proofs (save each as its own file, run with node)
// slow-config.mjs
console.log("[slow-config] module body starts, t=" + Date.now());
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
await delay(300);
console.log("[slow-config] finished awaiting, t=" + Date.now());
export const config = { apiUrl: "https://example.test/api" };

// sibling.mjs
console.log("[sibling] module body executing, t=" + Date.now());
export const value = 42;

// main.mjs
const t0 = Date.now();
console.log("[main] script file starts, t=" + t0);
import { config } from "./slow-config.mjs";
import { value } from "./sibling.mjs";
console.log("[main] both imports resolved, t=" + Date.now() + " (waited ~" + (Date.now() - t0) + "ms)");

// cycle-a.mjs
console.log("[a] body starts");
import { bValue } from "./cycle-b.mjs";
console.log("[a] body ends, bValue=", bValue);
export const aValue = 1;

// cycle-b.mjs
console.log("[b] body starts");
function delay(ms) { return new Promise((r) => setTimeout(r, ms)); }
await delay(50);
import { aValue } from "./cycle-a.mjs";           // real ReferenceError here
console.log("[b] body ends, aValue=", aValue);
export const bValue = 2;

// cycle-main.mjs
import { aValue } from "./cycle-a.mjs";
console.log("[main] got aValue=", aValue);

// REAL captured output from: node cycle-main.mjs
// [b] body starts
// ReferenceError: Cannot access 'aValue' before initialization
//     at file:///.../cycle-b.mjs:5:39
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 157 of 165 decoded in the JavaScript track. One more won't hurt.

Back to track