A single-page app lazy-loads a route with `await import('./routes/settings.js')`. In production, some users hit a blank screen because that load fails. How does `import()` surface the failure, and how would you handle it in the code?
answer
- a rejection, not a throw
- unhandled rejection means silent nothing
- fetch failure vs evaluation throw
- errors are memoised on the record
- reload onto the new build, once
basics
~20 simport() reports failure by rejecting its promise — nothing is thrown at the call site. Wrap the await in try/catch, distinguish a failed fetch from an error thrown while the module evaluates, show a real error state, and recover from a stale-deploy 404 with a full page reload rather than a bare retry.
solid answer
~60 s`import()` never throws synchronously: a bad specifier, a 404, a network failure or an exception inside the module body all come back as a **rejected promise**. If nothing awaits or catches it, the route simply never renders and you get an unhandled rejection instead of a visible error — the blank screen. The fix has three parts. First, wrap the call in `try`/`catch` (or attach `.catch`) and render a real error state with a retry affordance instead of nothing. Second, distinguish the two failure kinds: a *fetch* failure — browsers reject with a `TypeError` mentioning the failed dynamic import — versus an error thrown while the module's top-level code evaluates, which rejects with whatever the module threw. Third, retry carefully: an evaluation error is memoised on the module record, so re-importing the same specifier rejects with the same error without re-running anything. For the classic cause — a deploy replaced the hashed chunk the running page was asking for — the reliable recovery is a full page reload onto the new build, not a retry loop against a URL that is gone.
code
javascript · 26 linesconst RELOAD_FLAG = 'reloaded-for-failed-import';
function isLoadFailure(err) {
// a failed fetch surfaces as a TypeError; a module that threw during
// evaluation rejects with whatever it threw
return err instanceof TypeError;
}
export async function loadRoute(loader, name) {
try {
const ns = await loader();
sessionStorage.removeItem(RELOAD_FLAG);
return ns;
} catch (err) {
report(err, { route: name, build: window.__BUILD_ID__ });
if (isLoadFailure(err) && !sessionStorage.getItem(RELOAD_FLAG)) {
sessionStorage.setItem(RELOAD_FLAG, '1');
location.reload(); // once: get onto the current build
return null;
}
throw err; // let the router render its error state
}
}
// usage
loadRoute(() => import('./routes/settings.js'), 'settings');go deeper
Know that a failed import() rejects rather than throws, so lazy loading needs try/catch around the await or a .catch — otherwise the click just does nothing.
Explain the two failure classes and their different rejection values, and why an already-failed module is not re-evaluated when you import it again.
Diagnose the production case: recognise post-deploy chunk skew, apply a bounded one-shot reload, log the specifier and build id, and make sure every lazy load — including speculative warm-ups — has an owner for its rejection.
Own the policy across the app: a single loader wrapper with a defined recovery strategy, alerting on the failure rate after a deploy, and a deployment approach that keeps the previous build's units reachable long enough for open sessions.
## The failure is a rejection, never a throw Because `import()` evaluates to a promise, every failure mode arrives as a rejection: ```js import('./routes/settings.js') .then(ns => render(ns.default)) .catch(err => showError(err)); ``` With `await` inside `try`/`catch` this feels like a normal exception, but the distinction matters at the call sites where nobody awaits. A fire-and-forget `import('./x.js').then(...)` with no `.catch` produces an unhandled rejection: no exception propagates, the surrounding code carries on, and the user sees a control that does nothing. That is the blank screen in the question — not a crash, just work that silently never happened. ## The two failure classes They deserve different handling. **The module never loads.** The specifier cannot be resolved, the request 404s, the network is down, the response has a wrong or missing JavaScript MIME type, or a security policy blocks it. Browsers reject with a `TypeError` whose message names the failed dynamic import. Diagnostically, nothing of the module ran. **The module loads but throws while evaluating.** Its top-level code ran and threw — a missing global it assumed, a bad configuration read at import time, a failing top-level initialisation. The promise rejects with the value the module threw, so a custom error class survives and `instanceof` works. Distinguishing them changes what you tell the user and what you log. The first is usually transient or a deploy-skew problem; the second is a real bug in the loaded code and will not fix itself. ## Retrying is not as simple as looping A module record remembers its evaluation error. If evaluation threw, later imports of the same specifier reject with that same recorded error and do **not** re-run the module body — a naive `for` loop of three attempts fails three times instantly for zero benefit. Hosts also keep failed entries in the module map, so even a fetch failure is commonly served straight back from the map rather than retried over the network. Two consequences: - A retry against the exact same specifier is unreliable as a recovery strategy. - If you genuinely want a fresh attempt at a *fetch* failure, you need a different URL — typically a cache-busting query string — which produces a *separate* module instance, with its own evaluation of the module body and its own copy of any module-level state. That duplication is often worse than the original problem. ```js async function loadRoute(path, { bust = false } = {}) { const url = bust ? `${path}?retry=${Date.now()}` : path; return import(/* a separate specifier means a separate module record */ url); } ``` Use that sparingly and never for a module holding singleton state. ## The stale-deploy case This is the classic production story. A user has the page open. You deploy; the lazily loaded units of the previous build are replaced with newly named ones. The user clicks "Settings" and the running page requests a unit that no longer exists — 404, rejection, blank screen. Nothing is wrong with the code; the running page is simply from an older build. A retry cannot help: the file is gone. The recovery is to get the user onto the new build, i.e. a full page reload. Sensible handling looks like: ```js async function openSettings() { try { const { default: Settings } = await import('./routes/settings.js'); mount(Settings); } catch (err) { report(err); // always log it — this must not be silent if (isChunkLoadFailure(err) && !sessionStorage.getItem('reloadedForChunk')) { sessionStorage.setItem('reloadedForChunk', '1'); location.reload(); // once, onto the current build return; } showRouteError({ retry: openSettings }); } } ``` The one-shot guard matters: if the reload does not fix it — genuinely missing file, offline user — you must stop, or you have built a reload loop that makes the app unusable. Clear the flag on a successful load. ## Design points worth saying out loud - **Never let it be silent.** Every `import()` needs an owner for its rejection, whether that is an `await` inside `try`/`catch`, an attached `.catch`, or a router-level boundary that catches loads for every route in one place. - **Centralise.** A single `loadModule` helper that logs, classifies and applies the reload policy beats scattered `try`/`catch` blocks with different behaviour per route. - **Log with context.** Include the specifier and the build identifier; "failed to import ./routes/settings.js from build abc123" is actionable, a bare `TypeError` is not. - **Warming inherits the rule.** If you speculatively call `import()` on hover, attach a `.catch` to the speculative call too, or a failed prefetch becomes an unhandled rejection all by itself. - **Watch the rate.** A sudden spike in these rejections after a deploy is a strong signal of exactly this skew, which is why the failures belong in your error reporting, not just in a UI branch.
- Why does retrying the same specifier in a loop usually fail three times instantly?Because failure is remembered. A module that threw during evaluation has that error recorded on its module record, so later imports reject with the identical error without re-running the body, and hosts commonly keep failed fetch entries in the module map too. The loop never reaches the network. A genuine fresh attempt needs a different URL — which creates a separate module instance and duplicates any module-level state.
- How do you tell a missing file apart from a module that threw while evaluating?By the rejection value. A failed fetch or unresolvable specifier surfaces in browsers as a `TypeError` whose message references the failed dynamic import; a module that ran and threw rejects with exactly the value it threw, so a custom error class and its `instanceof` check survive. Nothing of the module ran in the first case, which is why the two need different user messaging and different alerting.
- You add a one-shot reload for failed route loads. What can go wrong, and how do you bound it?An unconditional reload becomes an infinite loop when the file is genuinely gone or the user is offline — every reload re-fails and reloads again. Bound it with a marker in sessionStorage set before reloading and cleared on the next successful load, so at most one reload happens per session; after that, fall through to a visible error state with a manual retry.
- A prefetch on hover fails. Why is that worse than it looks?A speculative `import()` with no handler produces an unhandled rejection for a load the user never asked for — noise in error reporting, and in some setups a visible console error or a global handler firing. Attach a `.catch` to speculative calls and let the real interaction path do the reporting, so warming can fail quietly without polluting the signal.
saying these in an interview costs you the question
- Expects import() to throw synchronously at the call site
- Wraps a retry loop around the same failing specifier
- Reloads on every failure with no loop guard
- Treats a 404 chunk and a throwing module identically
- Leaves lazy loads with no catch, so failures are silent