skip to content

Two ES modules import each other, and one of them top-level awaits a value the other only produces after its own body finishes. What happens when the application loads, and how would you track it down?

level: seniorimportance: should knowfreq 30%

answer

  1. cycle plus suspended evaluation
  2. each waits on the other's completion
  3. promise that never settles
  4. no error, no stack, no timeout
  5. bracket the last log that ran

basics

~20 s

Nothing happens: the graph's evaluation promise never settles, so the modules stay half-evaluated and the entry point never runs. There is no error, no stack trace and no timeout — the load simply hangs, which is why it is diagnosed by tracing where logging stops.

solid answer

~50 s

You get a deadlock with no diagnostic. Inside a cycle, one module's body suspends at its top-level `await` waiting on something the other module can only supply once it has finished evaluating — and that module cannot finish, because it is waiting on the first one. The module graph's evaluation promise never settles, so the entry module never runs. Crucially, the engine does not detect this: there is no error, no rejection and no timeout, so a browser page renders nothing and a process starts and does nothing. You diagnose it by finding the last thing that logged and the first thing that did not, which brackets the stuck module; then look for a top-level `await` inside a cycle. The fix is to take the await out of the cycle — break the cycle, or move the async work behind an exported function callers invoke explicitly.

go deeper

for a junior

Know that a module can wait forever if what it awaits depends on a module that is itself waiting, and that this shows up as a load that hangs rather than an error message.

for a middle

Explain the mechanism: the awaiting module's body is suspended, the other module cannot finish because it depends on the suspended one, and the graph's evaluation promise never settles. Note that a never-settling promise is not an error the engine can report.

for a senior

Demonstrate the diagnosis you would actually run: bracket the last statement that executed, add markers before and after each suspect await to find the module that entered and never resumed, then map the cycle. Be able to state the fix — break the cycle or move the async work behind an explicit initializer.

for a principal

Own the guardrails rather than the incident: decide where top-level await is permitted in your codebase, keep it out of modules that participate in cycles, and make silent-hang startups detectable with a readiness signal so a supervisor does not mistake a stuck process for a healthy one.

## The failure A cycle plus a top-level await can produce a load that never completes. It is worth being precise about why, because the failure mode is unusually unhelpful: nothing is thrown, nothing is rejected, nothing times out. The graph simply stops making progress and the program never starts. ```javascript // a.js import { ready } from './b.js'; const value = await ready; // waits for b.js to finish export const a = value; // b.js import { a } from './a.js'; export const ready = Promise.resolve(a); // resolved only when b's body runs ``` ## Why it locks Evaluation of a module body starts only after its dependencies have finished evaluating, and a top-level await suspends the awaiting module until the awaited promise settles. In a cycle, those two rules can point at each other. One member of the cycle starts evaluating and reaches its top-level `await`. What it is awaiting can only be settled by code in the other member — code that lives after that module's own imports have been satisfied, which means after the first module has finished. So module A is suspended pending a settlement that only B's completed body can produce, and B cannot run to completion because it is downstream of A, which is suspended. Nothing advances. The same shape appears when a module inside a cycle awaits a dynamic import of a module that is currently mid-evaluation in that same cycle: the returned promise settles only when that module finishes evaluating, and it cannot finish while the awaiting module is stuck. ## Why there is no error This is the part worth internalizing. Cycles themselves are legal in ES modules and are handled — the linking phase resolves the bindings and evaluation proceeds through the cycle in a defined order. What the engine does not do is prove that the promises you await will ever settle; that is the halting problem in miniature. A promise that never settles is not an error condition in JavaScript, it is just a promise, so there is nothing for the runtime to report. Contrast this with the failures people expect: a missing export is caught at link time, a throw during evaluation propagates to importers, and even a rejected top-level await produces a real error. A deadlock produces silence. The symptom, then, is absence. In a browser, the module script's evaluation never completes, so nothing that depends on it runs and the page stays as the server sent it. In a server process, startup logging stops partway and the process sits there, alive and idle, often looking to a supervisor like a healthy service that simply never became ready. ## Diagnosing it Because there is no stack, work from the boundary between what ran and what did not. - **Bracket it.** Find the last statement that produced output and the first expected one that did not. The stuck module is at or just above that boundary in the graph. - **Add evaluation markers.** A log at the top of each suspect module's body, plus one immediately after each top-level `await`, immediately shows which module entered and never resumed — a module that logs its start line but never its post-await line is your suspect. - **Look for the cycle.** Once you have the suspended module, check whether anything it awaits is reachable from a module that imports it, directly or through a chain. Cycles are often not visible in one file; a dependency-graph view or a search for imports of the suspect module gets you there faster than reading code. - **Do not expect a timeout to save you.** Wrapping the awaited promise in a timeout turns silence into an error, which is a real improvement for a network operation, but inside a cycle it converts a hang into a startup failure rather than fixing anything. ## Fixing it The reliable fix is to get the await out of the cycle: - **Break the cycle.** Usually the shared thing being passed back and forth belongs in a third module both can import, which removes the mutual dependency entirely. - **Move the async work out of evaluation.** Export an `async` initializer and have a coordinating module call and await it, instead of doing the work during module evaluation. Evaluation ordering then stops being load-bearing. - **Await less.** If the module only needs the value lazily, export a promise or a getter and let consumers await it at the point of use, so no module's evaluation depends on another's completion. The general rule that comes out of this: top-level await is safest in leaf-ish modules with a clean, acyclic set of dependencies. The moment a module participates in a cycle, making its evaluation depend on another module's completion is a hazard, and the failure it produces is one of the least informative in the language.

  • Why doesn't the engine detect this and throw a circular-dependency error?
    Cycles are legal and are resolved at link time, so the cycle itself is not the error. The stuck part is a promise that never settles, and no runtime can prove in general whether a promise will settle. From the engine's point of view everything is in a valid state, just pending — so there is nothing to report.
  • Would adding a timeout around the awaited promise fix the deadlock?
    It converts silence into a visible failure, which is genuinely useful for diagnosis, but it does not fix anything: the startup now fails fast instead of hanging. For a network operation a timeout is worth having on its own merits. For a cycle, the real fix is removing the await from the evaluation path so no module's completion depends on another's.
  • What is the safest structural rule for using top-level await given this failure mode?
    Confine it to modules with acyclic dependencies, ideally near the leaves of the graph, and never await anything whose settlement depends on a module that transitively imports you. If two modules genuinely need each other's values, extract the shared piece into a third module or move the async work behind an explicit initializer that a coordinator calls.

saying these in an interview costs you the question

  • Expects a circular-dependency error to be thrown
  • Assumes the runtime detects and breaks the deadlock
  • Thinks the modules load with undefined values instead of hanging
  • Says a timeout on the await resolves the cycle
  • Believes cycles are always illegal in ES modules

context