skip to content

Shortly after a deploy, users with a tab open on a React SPA hit a crash when they navigate to a route rendered through React.lazy. At the React level, where does that failure surface, does the Suspense fallback catch it, and what does it take to recover in the UI?

level: seniorimportance: should knowfreq 48%

answer

  1. only stale tabs, only after releases
  2. pending versus rejected are different states
  3. which boundary type catches a render throw
  4. the outcome is remembered, not retried
  5. the real fix lives in the deploy

basics

~20 s

The old tab requests a hashed chunk that the new deploy removed, so the loader's promise rejects and React.lazy throws during render. Suspense handles pending loads only — an error boundary above the lazy component catches it, and recovery normally means reloading the page.

solid answer

~50 s

A stale tab still references chunk filenames from the previous build; after the deploy those files are gone, so `import()` rejects and `React.lazy` rethrows that rejection during render. Suspense is not involved — it handles the *pending* state, not the failed one — so the error propagates to the nearest error boundary above the lazy component, and without one it takes the app down. Two things matter for recovery. React stores the settled outcome on the lazy component, so simply re-rendering the same lazy type rethrows the cached error instead of retrying the import; a genuine retry needs a fresh lazy component or, in practice, a full page reload. So the boundary's fallback should be a "this app was updated, reload" affordance rather than a silent retry button that cannot work. The systemic fix is on the deployment side: keep old chunk files served for a grace period.

code

javascript · 33 lines
javascript
import { Component, lazy, Suspense } from 'react';

class ChunkErrorBoundary extends Component {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  render() {
    if (this.state.failed) {
      return (
        <div role="alert">
          <p>This app was updated. Reload to continue.</p>
          <button onClick={() => window.location.reload()}>Reload</button>
        </div>
      );
    }
    return this.props.children;
  }
}

const Settings = lazy(() => import('./routes/Settings'));

export function SettingsRoute() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<p>Loading…</p>}>
        <Settings />
      </Suspense>
    </ChunkErrorBoundary>
  );
}

go deeper

for a junior

Know the division of labour: Suspense covers the loading state of a lazy component, and an error boundary covers the case where its code fails to load. Remember that a page reload fixes the stale-deploy version of this.

for a middle

Explain that the loader's rejection is rethrown during render, so it propagates to the nearest error boundary, and that React caches the settled outcome — including the failure — on the lazy component.

for a senior

Diagnose it from the pattern: only long-lived tabs, only after a release, hashed chunk names 404ing. Design the recovery honestly — a reload affordance rather than a retry that cannot succeed — and place boundaries so the app shell survives.

for a principal

Own the release-level answer: how long previous builds' assets stay served, how open clients learn a new version shipped, and what the error-reporting signal is that ties a spike in chunk failures back to a specific deploy.

## What actually broke Bundlers emit content-hashed filenames. A tab loaded before the deploy holds the previous build's module graph in memory, including the exact chunk names its lazy loaders will request. After a deploy replaces those files, the old names 404 (or return an HTML error page that fails to parse as a module). The user notices nothing until they navigate to a route whose code has not been fetched yet — which is precisely the route behind a lazy split. This is why the bug reports cluster right after releases, affect only long-lived tabs, and vanish on refresh. It is not a bug in your React code at all; React is simply where the failure becomes visible. ## Where React surfaces the failure `lazy` calls your loader and reads the resulting promise during render. Three outcomes: - **Pending** — React suspends and the nearest `<Suspense>` renders its `fallback`. - **Fulfilled** — React renders the module's default export. - **Rejected** — React throws the rejection reason *during render*. Candidates routinely expect Suspense to cover the third case. It does not. A Suspense boundary describes what to show while something is loading; it has no error slot. A render-phase throw travels up to the nearest **error boundary** — a class component implementing `static getDerivedStateFromError` and/or `componentDidCatch` — which renders its fallback UI instead of the crashed subtree. With no error boundary anywhere above, the throw reaches the root and React unmounts the whole tree, which is the blank screen users describe. So the minimum resilient shape is an error boundary *outside* a Suspense boundary: ```jsx <ChunkErrorBoundary> <Suspense fallback={<PageSkeleton />}> <Settings /> </Suspense> </ChunkErrorBoundary> ``` Nesting them the other way around is a common slip: an error boundary inside Suspense still catches errors from its own children, but a boundary placed around the Suspense is what keeps a failed route from taking out the shell. ## Why a retry button usually does not work React caches the outcome of the loader on the lazy component itself — that is what makes a second render of an already-loaded route free. The cache stores rejection too. Once the load has failed, rendering that same lazy component again rethrows the stored error without calling the loader a second time. A naive "Try again" button that just resets the error boundary's state therefore fails instantly and looks broken. What can actually recover: - **A full reload** (`window.location.reload()`), which re-fetches the current entry HTML and with it the new build's chunk names. This is the honest fix for the stale-deploy case, because the old chunk genuinely no longer exists — no amount of retrying will find it. - **A newly created lazy component**, if you want a retry for transient network failures rather than missing files. Because identity is what carries the cache, recovering means constructing a *different* lazy component — for instance keeping the loader in a module-level factory and asking it for a fresh lazy on retry, then remounting the subtree with a changed `key`. This is only worth building where the failure mode is flaky connectivity. Because the two causes want different remedies, teams often distinguish them: if the app's build identifier has changed since the tab loaded, show "a new version is available — reload"; otherwise offer a retry. ## Making the boundary's UI honest The fallback the user sees should match what will actually help. A generic "Something went wrong" leaves them stuck on a broken screen. A chunk-load boundary typically says the application has been updated and offers a reload button, preserves whatever navigation shell sits above it so the user is not stranded, and reports the error so the release can be correlated with the spike. Placing the boundary at the route level, rather than one global boundary at the root, means a failed lazy route degrades to a message inside the app frame instead of a white page. ## Preventing it upstream The durable fix is not in React: keep the previous build's chunk files available for a grace period after deploying, so old tabs can still resolve them, and expire them well after typical session length. Complementary tactics include notifying open clients that a new build exists and prompting a reload at a safe moment. Those are deployment and delivery decisions, but a senior candidate is expected to name them — the React-side error boundary is damage control, not the cure. ## The short version to say out loud The loader's promise rejects; `lazy` rethrows during render; Suspense does not catch errors, an error boundary does; the rejection is cached on the lazy component so re-rendering rethrows rather than retrying; recovery is a reload, and prevention is retaining old chunks across deploys.

  • Why does a Suspense boundary not catch this, given that it already wraps the lazy component?
    Suspense models one thing: a subtree that cannot render *yet*. It has a `fallback` for the pending state and no error slot at all. A rejected loader is a thrown value during render, and render-phase throws are the error boundary's domain — a class component with `getDerivedStateFromError` or `componentDidCatch`. The two boundaries are complementary, which is why resilient code wraps a Suspense boundary in an error boundary.
  • Your error boundary shows a "Retry" button that re-renders the same lazy component. Why does it fail immediately?
    Because the rejection is cached on that lazy component. React stores the settled result of the loader so repeat renders are free, and a rejected result is stored just as a fulfilled one is — re-rendering rethrows the stored error without calling the loader again. A working retry has to produce a *new* lazy component (and remount the subtree), or reload the page, which is the right remedy when the chunk file is genuinely gone.
  • Where would you place the error boundary for lazy routes, and why not just one at the root?
    Put one at the route level, outside each route's Suspense boundary. A root-only boundary means a single failed chunk blanks the entire application, including the navigation shell the user needs to escape. A route-level boundary degrades gracefully: the frame stays, the failed area shows an "application updated, reload" message, and the error can be reported with the route as context.
  • How do you stop this happening for the next release rather than handling it in the UI?
    Keep the previous build's chunk files served after deploying, for longer than a typical session, so tabs holding the old module graph can still resolve what they ask for. Pair that with a way to tell open clients a new build exists and invite a reload at a safe moment. The error boundary remains as a backstop, but the fix belongs in how the build's assets are published and expired.

saying these in an interview costs you the question

  • Expecting the Suspense fallback to handle the failed import
  • Assuming React retries the import automatically on re-render
  • Offering a retry that re-renders the same lazy component
  • Wrapping only the root in a single error boundary
  • Calling it a React bug rather than a stale-asset problem

context