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>
);
}
Step-by-step #
- Retry inside the factory.
React.lazycaches the promise it is given, so retrying inside the factory is the only place a second attempt can happen without a component remount. - 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.txtanswers the question in one request. - 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. - 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.
- 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.
- Preload on intent.
onMouseEnter/onFocuscalling 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 |
Verification #
- 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. - Simulate a deploy. Change
/build-id.txtwhile the tab is open, block the chunk, and confirm the fallback switches to the reload wording rather than offering a doomed retry. - 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.
- 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. - 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.
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.
Related #
- Component Isolation Techniques — the wider isolation model this applies to code splitting
- Isolating Third-Party Widget Crashes with a Sandbox Boundary — the same containment idea for code you do not control
- Choosing a Boundary Granularity Strategy — deciding how many boundaries a routed application needs