This walkthrough applies the isolation principles from Component Isolation Techniques to the code-splitting boundary, where the failure is a missing file rather than a bad render.

The exact problem #

The user taps “Reports” in the navigation. The route is code-split, so the browser starts downloading reports-8f2b1c.js. The request fails — a flaky hotel Wi-Fi, a CDN hiccup, or a deploy fifteen minutes ago that removed that exact file. React’s lazy re-throws the rejection, the nearest boundary is at the root, and the entire application — navigation included — is replaced by a full-page error.

Three separate defects are stacked in that sentence: no retry for a transient failure, no special handling for the stale-deploy case, and a boundary placed so high that a routed failure destroys the shell.

Zero-to-working setup #

// lazyWithRetry.ts
type Importer<T> = () => Promise<{ default: T }>;

const STALE_MARKERS = /ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module|error loading dynamically imported module/i;

export class StaleBundleError extends Error {
  name = 'StaleBundleError';
}

/** Retry a dynamic import before the rejection ever reaches React. */
export function lazyWithRetry<T extends React.ComponentType<never>>(
  factory: Importer<T>,
  { attempts = 3, baseDelay = 250 } = {},
) {
  return React.lazy(async () => {
    let lastError: unknown;
    for (let attempt = 1; attempt <= attempts; attempt += 1) {
      try {
        return await factory();
      } catch (err) {
        lastError = err;
        const message = err instanceof Error ? err.message : String(err);

        // A removed chunk will never appear; retrying is pure latency.
        if (STALE_MARKERS.test(message) && (await bundleChanged())) {
          throw new StaleBundleError('bundle replaced by a newer deploy');
        }
        if (attempt === attempts) break;
        await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1) + Math.random() * 120));
      }
    }
    throw lastError instanceof Error ? lastError : new Error('chunk load failed');
  });
}

/** Cheap deploy check: compare the build id the page loaded with the current one. */
async function bundleChanged(): Promise<boolean> {
  try {
    const res = await fetch('/build-id.txt', { cache: 'no-store' });
    return res.ok && (await res.text()).trim() !== __BUILD_ID__;
  } catch {
    return false;   // offline: treat as transient, not stale
  }
}
// routes.tsx — placement is the other half of the fix
const Reports = lazyWithRetry(() => import('./routes/Reports'));

export function AppLayout() {
  const location = useLocation();

  return (
    <div className="shell">
      <SiteHeader />           {/* never inside the boundary */}
      <SiteNav />
      <main>
        {/* keying by pathname resets the boundary on navigation, so a failed
            route does not keep its error state when the user goes elsewhere */}
        <RouteBoundary key={location.pathname} scope={`route:${location.pathname}`}>
          <Suspense fallback={<RouteSkeleton />}>
            <Outlet />
          </Suspense>
        </RouteBoundary>
      </main>
      <SiteFooter />
    </div>
  );
}
Boundary placement decides what a failed chunk destroys Two shell diagrams. On the left the boundary wraps the entire application, so a failed route shows one full-page fallback covering header, navigation and content. On the right the header and navigation sit outside the boundary and only the outlet region is replaced by a fallback. boundary at the root boundary inside the layout full-page fallback header and nav gone; user is stranded header + navigation (alive) outlet fallback + retry user can navigate elsewhere footer (alive)

Step-by-step #

  1. Retry inside the factory. React.lazy caches the promise it is given, so retrying inside the factory is the only place a second attempt can happen without a component remount.
  2. Distinguish stale from transient. Retrying a chunk that no longer exists wastes three round trips and still fails. Comparing the loaded build id against /build-id.txt answers the question in one request.
  3. Throw a typed StaleBundleError. The fallback can then offer “Reload to get the latest version” instead of a generic retry, which is the only recovery that works after a deploy.
  4. Keep Suspense inside the boundary. The boundary must outlive the pending state so it can catch failures both during loading and during the first render of the loaded component.
  5. Key the boundary by pathname. Without the key, a boundary that entered its error state stays errored when the user navigates to a healthy route — a stuck fallback that looks like a total outage.
  6. Preload on intent. onMouseEnter/onFocus calling the same import factory warms the module cache, so the common case never touches this failure path at all.
// StaleAwareFallback.tsx
export function RouteFallback({ error, reset }: { error: Error; reset: () => void }) {
  const stale = error.name === 'StaleBundleError';

  return (
    <div role="alert" className="route-fallback">
      <h2>{stale ? 'A new version is available' : 'This section did not load'}</h2>
      <p>
        {stale
          ? 'The app was updated while this tab was open. Reloading picks up the new version.'
          : 'The connection dropped while loading this page. You can try again without losing your place.'}
      </p>
      {stale ? (
        <button type="button" onClick={() => window.location.reload()}>Reload</button>
      ) : (
        <button type="button" onClick={reset}>Try again</button>
      )}
    </div>
  );
}

Edge cases #

Case Symptom Handling
Deploy during an open session ChunkLoadError on the next navigation only Build-id check, then offer reload — never silent auto-reload mid-form
Offline navigation Import rejects instantly, build-id check also fails Treat as transient; surface the offline state from Connectivity Detection & Offline UX Patterns
Service worker serving an old precache Chunk 200s but the module graph mismatches Version the precache per build; see App Shell Precache & Offline Fallback Routing
Import succeeds, component throws on first render Same boundary catches it Keep the generic retry path; do not assume every catch is a chunk error
Retry storm across many tabs after a CDN incident Repeated failures amplify load Jittered backoff, capped attempts
Preload on hover on a slow connection Wasted data on touch devices Gate preloading on navigator.connection.saveData and pointer type
Retry and stale-deploy decision flow for a lazy import A vertical flow. Import attempt leads to either rendered route on success or a build-id check on failure. If the build id changed, the flow goes to a stale bundle error and a reload prompt. If not, it goes to a backoff retry, which loops back to the import attempt up to three times before reaching the boundary fallback with a try again control. import() attempt resolved rejected route renders build id changed? no yes backoff retry (max 3) then boundary fallback StaleBundleError offer reload

Verification #

  1. Block the chunk in DevTools. Network → request blocking on *reports*.js, then navigate. You should see three attempts spaced by backoff, then the fallback — with navigation still usable.
  2. Simulate a deploy. Change /build-id.txt while the tab is open, block the chunk, and confirm the fallback switches to the reload wording rather than offering a doomed retry.
  3. Throttle to Slow 3G. Confirm the Suspense skeleton appears rather than a blank area, and that the retry does not fire while a request is still in flight.
  4. Navigate away from a failed route. With the key={location.pathname} in place, a different route must render normally; without it, the fallback persists — the regression this key prevents.
  5. Check reporting. The boundary should send one crash report with scope: route:/reports; a stale-bundle case should be distinguishable by error name, per the payload in Building a Structured Error Payload for Crash Reports.
Retry timing versus a preloaded navigation Two timelines. The upper one shows three import attempts separated by increasing gaps labelled 250, 500 and 1000 milliseconds, ending in a fallback render at about two seconds. The lower one shows a preload triggered on hover followed by an instant resolve at navigation time. cold navigation with retries 250 ms 500 ms 1000 ms fallback renders preloaded on hover import starts on pointer intent click → module already cached

Frequently asked questions #

Why does a failed lazy import blank my whole page?

React.lazy re-throws the import rejection to the nearest boundary. If that boundary is at the root, the fallback replaces everything including navigation. Mount a boundary between the shell and the router outlet.

What causes ChunkLoadError on a site that was working a minute ago?

A deploy replaced the hashed chunk files while the user still had the old module graph. The chunk URL 404s. This is the one case where a full reload is the correct recovery.

Should Suspense sit inside or outside the error boundary?

Inside. The boundary must survive the pending state so it can catch failures during loading and during the loaded component’s first render.