skip to content

When is top-level await in an ES module the right way to do async initialization, and when would you instead export an initializer function or a promise?

level: principalimportance: should knowfreq 26%

answer

  1. who is forced to wait
  2. fan-in decides the blast radius
  3. static import has no catch
  4. failure handling wants a caller
  5. library packaging is a one-way door

basics

~20 s

Use top-level await when a module's export is meaningless until an async step completes and every consumer needs it — it makes correct ordering structural. Prefer an exported initializer or promise when the module is widely imported, when the work can fail or needs retry control, or when consumers must load synchronously.

solid answer

~50 s

Top-level await buys one thing: the module system enforces that nobody can observe a not-yet-ready export. That is worth a lot for a module whose entire value is an async-loaded artifact — a resolved configuration, an instantiated WebAssembly module, a chosen implementation — where every consumer needs it and a half-initialized export would be a bug. What you pay is coupling: every importer's evaluation, up to the entry point, now waits on that operation and fails with it, and a static importer has no place to catch it. So I avoid it when the module is imported broadly and only sometimes needs the value, when the work is failure-prone and wants retry, timeout or fallback, or when consumers must be able to load the module synchronously. In those cases I export an `async` initializer, or a promise consumers await at the point of use, so the cost lands on the callers who actually need it.

go deeper

for a junior

Know both options exist: awaiting at the module's top level so importers get a ready value, or exporting a function or promise that callers await themselves. Be able to say the first delays whoever imports the module.

for a middle

Explain the mechanical difference — top-level await delays every importer up the chain while an exported promise delays only code that awaits it — and note that a static importer has no way to catch a failed evaluation.

for a senior

Show that you weigh import fan-in and failure behaviour: an await on a widely imported module taxes startup for code that never uses the value, and network work that needs retry or fallback belongs under a caller's control, not in evaluation.

for a principal

Own it as a policy and packaging decision: where top-level await is allowed in the codebase, what it does to a published library's consumers, and how startup latency is budgeted across the import graph. Be ready to say why you would forbid it on hot import paths.

## What the decision is really about Both designs solve the same problem — a module needs something asynchronous before it is useful — and they differ in *who* is forced to wait and *where* a failure surfaces. Top-level await moves both concerns into the module system. The importer cannot proceed until the value exists, so the ordering is guaranteed by the language rather than by discipline. An exported initializer or promise moves both concerns into user code: the module loads instantly, and each consumer decides when to await and what to do if it fails. ## The case for top-level await It is the right tool when three things are true at once. First, the module's export is genuinely meaningless before the async step. A module that exists to provide a parsed configuration object, an instantiated WebAssembly export table, or a driver selected at load time has nothing sensible to expose in the meantime. Exporting a placeholder would just invite someone to read it too early. Second, essentially every consumer needs the value. If the module is only imported by the handful of places that use the value, the delay is not being imposed on anyone who does not benefit from it. Third, a failure genuinely should stop the program. If the app cannot function without the value, then failing the load loudly is better than starting up in a broken state. When those hold, top-level await removes an entire class of bugs by construction. The alternative in this situation is a convention — "always await `ready` before touching anything" — and conventions are exactly what a new contributor breaks in month three, with a failure that is intermittent and hard to reproduce. ```javascript // runtime.js — the export has no meaningful value before this completes export const runtime = await instantiateRuntime(); ``` ## The case against it The costs are all consequences of one fact: the module system, not the caller, owns the waiting and the failure. **Latency propagates to the whole ancestor chain.** Anything that imports this module, transitively up to the entry point, starts later. If the module sits on a hot import path — a utility, a logger, a shared constants file — one await can dominate time-to-first-work for code that never touches the awaited value. **Failures cannot be handled by static importers.** There is no place to write a `catch` around a static `import`, so a rejected top-level await takes down the load of everything above it. If you want a fallback, a retry with backoff, a timeout, or degraded operation, the awaited work must be under a caller's control, not part of evaluation. **Consumption becomes asynchronous-only.** A module whose graph contains a top-level await cannot be loaded synchronously; runtimes that otherwise permit synchronous consumption of ES modules refuse when the graph is async, and non-ESM output formats cannot express a module that suspends. For a published library, this narrows who can consume you. **It interacts badly with cycles.** If the module ends up in an import cycle, a top-level await can produce a load that hangs with no error at all — the least diagnosable failure in this area. ## What the alternatives give up and gain An exported `async` initializer (`export async function init()`) gives callers total control: they choose when to run it, can retry or time out, can supply configuration, and can decide what a failure means. The price is that the module now has a lifecycle that consumers must respect, and nothing prevents a consumer from using it before `init()` resolves. That has to be defended against — throwing a clear error from accessors before initialization is the usual approach, and a clear error beats a silent placeholder. An exported promise (`export const ready = load()`) is a middle position: the work starts at module load, so it overlaps with everything else, but consumers await it at the point of use rather than at the point of import. Nothing is blocked, and each consumer can catch the rejection. The cost is that the promise is a shared object whose rejection needs handling somewhere, and the discipline of awaiting it is once again a convention. Lazy initialization inside an accessor — start the work on first use and memoize the promise — costs nothing at startup and is the natural fit for something only some code paths need. ## How I would decide Start from the import fan-in. A module imported by three call sites that all need the value is a fine candidate for top-level await. A module imported by two hundred files is not, regardless of how convenient the ergonomics look. Then ask whether the operation can fail in a way you want to handle: anything reaching the network usually does, and that argues for caller-controlled initialization. Finally consider the consumers you are shipping to — an application you fully control has more freedom here than a published library, where making the whole package asynchronous-only is a compatibility decision you cannot easily take back.

  • Why can't a static importer just handle a failing top-level await with a try/catch?
    There is nowhere to put it. A static `import` is a declaration processed before any of the importing module's code runs, so no user code is on the stack when the dependency fails. The failure propagates as a module-evaluation error to the host. If you need to handle it, the async work has to be inside something a caller invokes, or reached through a dynamic import whose promise can be caught.
  • What is the main weakness of exporting an async init() function instead?
    Nothing enforces that consumers call and await it before using the module, so ordering becomes a convention. The usual mitigation is to make accessors throw a clear error until initialization has completed, which turns a silent wrong-value bug into a loud, immediate one. You trade a guarantee from the module system for control over timing and failure handling.
  • How does exporting a promise differ from top-level await in what consumers experience?
    The work starts at load time in both cases, but exporting a promise does not delay any importer's evaluation — consumers await it where they use the value, so unrelated code paths are unaffected, and each consumer can catch a rejection. The tradeoff is that awaiting it is a convention rather than a guarantee, and the shared rejection must be handled somewhere.

saying these in an interview costs you the question

  • Treats top-level await as always cleaner than an init function
  • Ignores that importers cannot catch a failed evaluation
  • Overlooks fan-in when judging startup cost
  • Assumes it is free because it does not block the thread
  • Adopts it in a published library without considering consumers

context