A top-level `await` in an ES module rejects. What happens to that module and to the modules that import it, and can an importer catch the error with try/catch?
answer
- evaluation itself fails
- dependents inherit the failure
- static import has no catch position
- only dynamic import() is wrappable
- failure is cached, not retried
basics
~20 sThe module's evaluation fails with that error, and the failure propagates to every module importing it, which never runs. An importer cannot wrap a static import in try/catch, so the error must be handled inside the awaiting module or by whatever started the graph.
solid answer
~50 sA rejected top-level `await` makes the module's evaluation itself fail. The rejection becomes the module's evaluation error, and every dependent inherits it — their bodies never run. Because `import` is a static declaration, not a statement you can wrap, an importing module has no `try`/`catch` position to put around it. The error surfaces wherever the graph was started: the `import()` promise rejects, or a browser module script reports it as a script error, or a Node entry point terminates with a non-zero exit code. The module is also *memoised as failed* — importing it again returns the same error rather than re-running the body. So if the failure is recoverable, catch it inside the awaiting module and export a sentinel or a fallback; if it is not, let it fail loudly at the entry point.
code
javascript · 17 lines// settings.mjs — handle it here so importers always get a usable module
const FALLBACK = { theme: 'light', retries: 3 };
let settings;
let degraded = false;
try {
const res = await fetch('/settings.json');
if (!res.ok) throw new Error(`settings ${res.status}`);
settings = await res.json();
} catch (cause) {
settings = FALLBACK;
degraded = true;
console.warn('settings unavailable, using defaults', cause);
}
export { settings, degraded };go deeper
Know that a rejected top-level await means the module fails to load and anything importing it does not run. Do not claim you can put try/catch around an import statement.
Explain why: import declarations are static and hoisted, so there is no execution point where a surrounding catch is active, and evaluation failure propagates to every dependent. Name dynamic import() as the wrappable alternative.
Demonstrate the operational angle — a failed top-level await is a startup failure, module failure is cached so naive retry is a no-op, and the choice between failing fast and exporting a degraded fallback is a deliberate one you should be able to defend.
Own the initialisation contract for the codebase: decide whether asynchronous setup failures should abort the process at boot or degrade to a fallback path, and make that consistent, observable and testable rather than per-module improvisation.
## Evaluation failure, not a thrown value When a module's top-level `await` rejects and nothing in the module catches it, the module does not merely log an error — its **evaluation fails**. The module system records that failure against the module record, and the module never becomes usable. ```js // config.mjs const res = await fetch('/config.json'); if (!res.ok) throw new Error(`config ${res.status}`); export const config = await res.json(); ``` If the `fetch` rejects (network down) or the explicit `throw` fires, `config.mjs` has no exported `config` and never will. ## The failure travels up, exactly like the waiting does Just as a pending top-level await defers dependents, a failed one *fails* them. Every module that imports the broken module — directly or transitively — has its own evaluation rejected with the same error, and its body never runs. This is the correct behaviour: an importer whose dependency could not initialise has no safe way to continue. ## Why try/catch around an import does not work Candidates often propose this: ```js // does not work — import declarations are not statements try { import { config } from './config.mjs'; } catch (err) { /* ... */ } ``` That is a SyntaxError. `import` declarations are static: they are hoisted, they must appear at module top level, and they participate in linking before any code runs. There is no execution point at which a surrounding `try` block is active. The only expression form that *can* be wrapped is dynamic `import()`, which returns a promise: ```js let config; try { ({ config } = await import('./config.mjs')); } catch (err) { config = defaults; } ``` That is the real answer when you genuinely need per-import recovery. ## Where the error actually surfaces If nobody in the graph catches it, the error reaches whatever kicked off the evaluation: - A dynamic `import()` promise rejects with it, so `.catch()` or `try`/`await` at that call site sees it. - In a browser, a `<script type="module">` entry point reports it the way an uncaught error in a module script is reported — it does not silently disappear, but no importing module gets a chance to handle it. - In a Node entry point, the process reports the unhandled rejection and exits non-zero by default. The practical effect in all three hosts is the same: a failed top-level await is a startup failure, not a recoverable runtime error in the importer. ## Failure is memoised Module records are cached by resolved specifier, and that cache stores *errors* as well as successes. If `config.mjs` failed to evaluate, a later `import('./config.mjs')` does not retry the fetch — it rejects immediately with the same error object. This is deliberate (modules must evaluate at most once) but it defeats the naive retry: ```js // pointless — the second call cannot re-run the module body let ns; try { ns = await import('./config.mjs'); } catch { ns = await import('./config.mjs'); } ``` If you need retry semantics, the retryable operation has to live inside an exported function, not in module scope. ## Designing for it Three defensible positions, and you should be able to name which you are taking: 1. **Fail fast.** Let the rejection kill startup. Correct for genuinely required setup — a missing database URL, an unparseable config. Loud and early beats a service that boots into a broken state. 2. **Handle inside the module.** Wrap the top-level await in `try`/`catch` and export a fallback, a null-object, or a status flag. The module always evaluates successfully; consumers branch on the exported state. 3. **Do not use top-level await at all.** Export an async initialiser and let the caller decide about retries, timeouts and fallbacks, where a normal `try`/`catch` works. ## Interview framing The question separates people who have only read the syntax from people who have shipped it. The two facts that matter are that a static `import` gives the importer no handling position, and that a failed module stays failed for the life of the graph.
- How would you make a failed asynchronous initialisation retryable?Move the operation out of module scope. Export an async function that performs the work and memoises only its successful result — for example, cache the promise but clear the cache on rejection so the next call retries. Module records themselves cache failures permanently, so anything in module scope gets exactly one attempt.
- If the awaiting module catches its own rejection, what do importers see?A normal, successful module. Once the top-level await's rejection is caught, the body runs to completion and the module's evaluation fulfils, so dependents proceed as usual — they see whatever bindings the catch path assigned. That is why exporting an explicit fallback or status flag is the clean way to make failure recoverable.
- Does dynamic import() give an importer any handling ability that a static import lacks?Yes — it is an expression returning a promise, so it can sit inside try/catch or take a .catch handler, and you can choose a fallback module or default value at that point. It does not make a failed module retryable, though: the module record's error is cached, so a second import() rejects immediately with the same error.
saying these in an interview costs you the question
- Says you can wrap a static import in try/catch
- Thinks importers still run with the binding undefined
- Claims re-importing retries the failed module body
- Says the rejection is silently swallowed by the module system
- Believes only the direct importer is affected