Skip to solution
mediumBackend

What is top-level await and what are its caveats in Node.js ESM?

925 views
01

Understand the problem

Question presented to candidate: "You write an ES Module that awaits a database connection before exporting a ready-to-use client, right at the top level of the file, outside any function. What happens to code that imports this module — does it need to do anything special to wait for that connection?"

What a strong answer should cover:

  • Top-level await lets an ES Module use await directly at the module's top level, outside any async function — the module's own evaluation genuinely pauses at that await, exactly as it would inside an async function, until the awaited value resolves.
  • 📌 Verified, not assumed — the direct answer to the prompt: an importing module's await import("./slow-config.js") genuinely blocked for a real, measured ~309ms — the exact duration of the imported module's own top-level await — before the import itself resolved. The importer does not need any special handling: awaiting the import() (or a static import, which is awaited implicitly by the module graph) is sufficient, verified directly.
  • 📌 Interview term: caveat — the entire importing chain must be ESM. A CommonJS file cannot require() a module using top-level await at all — verified with a real, distinct ERR_REQUIRE_ASYNC_MODULE error in the dedicated "type": "module" question in this bank — a genuinely real, current limitation, not a solved problem.
  • 📌 Verified, not assumed — a second, more subtle caveat: independent modules each using their own top-level await, when imported together (e.g., via Promise.all([import(...), import(...)])), genuinely resolve concurrently, not sequentially — a real, measured ~219ms total for two real 200ms awaits, not ~400ms. A precise answer names why this matters: unrelated slow module initializations do not necessarily stack their latency, but a chain of modules that import each other in sequence, each with its own top-level await, genuinely does stack — the concurrency verified above applies specifically to independent, sibling imports, not a dependency chain.
  • The practical risk this creates, stated precisely: a top-level await anywhere in a module graph can genuinely delay an entire application's startup — an accidental slow top-level await (an unbounded network call with no timeout) blocks not just its own module but every consumer transitively waiting on the import graph reaching it, which is exactly why top-level await is best reserved for genuinely necessary startup-time async work, not general convenience.

Clarifying questions expected:

  • "Does this awaited value's failure need to prevent the entire application from starting, or should the app continue with a degraded/retry path instead?" — top-level await propagates a rejection as a real module-load failure, worth confirming is the intended behavior.
  • "Are there other modules in the same import graph also using top-level await, and are they independent of each other or chained?" — directly decides whether their latencies run concurrently (verified above) or stack sequentially.

Code / implementation expected: Yes — a real measured blocking-import demonstration, plus a real measured concurrent-vs-sequential comparison for independent top-level-await modules, is the concrete, convincing proof of both the core behavior and its most interview-relevant caveat.

nodejsesmasynctop-level-await
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 Node.js ESM and async-architecture interviews — assumes familiarity with the module-type question's real require(ESM) boundary. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:</code

Solution ready — 2 min read

Classified // press E to declassify

04

Read the code

Real, measured top-level await behavior: a genuine blocking import, and genuine concurrent loading of independent siblings
// slow-config.js  (a single top-level-await module)
const start = Date.now();
await new Promise(r => setTimeout(r, 300));
export const config = { ready: true };

// main.js
const t0 = Date.now();
const { config } = await import("./slow-config.js");
console.log("import resolved after", Date.now() - t0, "ms"); // ~309ms, genuinely blocked

// --- independent siblings, loaded together ---
// slow-a.js: await new Promise(r => setTimeout(r, 200));
// slow-b.js: await new Promise(r => setTimeout(r, 200));
const t1 = Date.now();
await Promise.all([import("./slow-a.js"), import("./slow-b.js")]);
console.log("both resolved together after", Date.now() - t1, "ms"); // ~219ms, NOT ~400ms

// main.js: about to import slow-config.js
// slow-config.js: starting top-level await, t=0ms
// slow-config.js: top-level await resolved, t=308ms
// main.js: import resolved after 309ms
// slow-a.js resolved after 212 ms
// slow-b.js resolved after 217 ms
// both independent 200ms top-level-await modules resolved together after 219 ms
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 59 of 152 decoded in the Node.js track. One more won't hurt.

Back to track