Skip to the solution
HardSystem Design6 min read

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

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.

  • esm
  • top-level-await
  • modules

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 10 sections

Run the code

Edit the code and run it here. Each console line with an expected value becomes a test.

JavaScriptA real, runnable browser demo: a Blob-backed ES module with a genuine top-level await, imported dynamically (matches this doc's live-verified timestamps)
JavaScriptReference: 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

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 157 of 165 in JavaScript

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you implement a curry() function that supports partial application with placeholder arguments?

Continue