Error Boundaries with React Suspense and lazy
React.lazy and <Suspense> split your bundle into chunks that load on demand, but a Suspense fallback only covers the pending state — when a chunk request fails, the lazy component’s promise rejects and that rejection throws straight into render, leaving Suspense powerless. This how-to shows how to wrap a lazy route in an error boundary that catches the failed import and offers a working retry, extending the patterns in Implementing React Error Boundaries for Production within the broader discipline of Core JavaScript Error Handling & Boundaries. Because a failed chunk after a deploy is essentially a stuck-state problem, it pairs closely with Resetting React Error Boundaries on Route Change and with How to Gracefully Degrade UI on Component Failure.
Symptom / Trigger
You deploy a new build, and a user who still has the old index.html open clicks a link to a code-split route. The router calls the lazy component, the browser requests the chunk at its old hashed filename, and the CDN returns a 404 because that filename no longer exists. Instead of the Suspense spinner resolving into the page, the entire subtree unmounts and the screen goes blank. The console shows a chunk-load failure that React surfaces as a thrown error during render:
Uncaught (in promise) Error: Failed to fetch dynamically imported module:
https://app.example.com/assets/Dashboard-a1b2c3d4.js
The above error occurred in one of your React components:
at Lazy
at Suspense
at Routes (http://app.example.com/assets/index-9f8e7d.js:2:14503)
Consider adding an error boundary to your tree to customize error handling behavior.
The key line is the last one — React itself tells you a Suspense fallback is not enough. The pending promise rejected, and there is no boundary above <Suspense> to catch it, so the tree unwinds to the nearest ancestor that can, which in an unprotected app is the root. What makes this failure mode confusing is that it is intermittent and environment-specific: it never reproduces on the machine that shipped the deploy, only on clients whose tabs straddled the release. That timing dependence is why chunk-load failures so often slip through review and only surface as a spike of blank-screen sessions in production analytics hours after a deploy goes out.
Root Cause Explanation
React.lazy(factory) calls your factory (a () => import('./X')) exactly once and caches the returned promise. Suspense reads that promise: while it is pending, Suspense renders its fallback; when it resolves, Suspense renders the module’s default export. Suspense has no branch for a rejected promise — rejection is an error condition, and React routes errors to error boundaries, not to Suspense fallbacks. So <Suspense> and an error boundary are two halves of the same mechanism: one handles “not ready yet,” the other handles “failed to become ready.”
The broken pattern is a lazy component wrapped in Suspense with no boundary anywhere above it:
// App.jsx — BROKEN: Suspense with no error boundary above the lazy import
import { Suspense, lazy } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
export default function App() {
return (
// If the Dashboard chunk 404s, this promise rejects and throws.
// Suspense only handles the pending state — nothing catches the throw.
<Suspense fallback={<p>Loading…</p>}>
<Dashboard />
</Suspense>
);
}
Worse, because React.lazy caches the rejected promise, even a component that re-renders will get the same rejected promise back — the import is never retried. That is why a naive “wrap it and forget” boundary can catch the error but can never recover from it: clicking a retry button re-renders the same cached failure. The cache lives on the lazy component object itself, which is created once at module scope, so it survives every re-render and every boundary reset for the lifetime of the page. The only way to clear it is to replace the lazy component with a new one, which in practice means remounting the subtree so React constructs a fresh instance and calls your import factory again from scratch.
Step-by-Step Fix
1. Wrap the Suspense boundary in an error boundary
Place the error boundary outside <Suspense> so it can catch the rejected import while Suspense still handles the pending spinner:
// SafeLazyRoute.jsx — boundary outside, Suspense inside
import { Suspense, lazy } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const Dashboard = lazy(() => import('./Dashboard'));
function ChunkErrorFallback({ resetErrorBoundary }) {
// Shown when the dynamic import rejects (e.g. stale chunk 404).
return (
<div role="alert">
<p>This section failed to load.</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
export default function SafeLazyRoute() {
return (
<ErrorBoundary FallbackComponent={ChunkErrorFallback}>
<Suspense fallback={<p>Loading…</p>}>
<Dashboard />
</Suspense>
</ErrorBoundary>
);
}
The ordering here is the whole trick. Because errors propagate upward to the nearest boundary, the boundary has to sit above the Suspense node whose child threw. If you nest it the other way, the rejection escapes past Suspense before any boundary sees it. With this structure the spinner covers the normal pending state and the fallback covers the failure, and the two never fight over the same render.
2. Make the lazy factory retryable so resets actually reload the chunk
Because React.lazy caches the rejected promise, wrap the import in a factory that gets a fresh promise on each attempt. Centralizing the import in a small helper also gives you one place to add logging, back-off, or a cache-busting query string later:
// lazyWithRetry.js — returns a lazy component whose import can be re-run
import { lazy } from 'react';
export function lazyWithRetry(factory) {
// A version counter forces React.lazy to call factory again after a reset.
let attempt = 0;
return lazy(() => factory().catch((error) => {
attempt += 1;
// Re-throw so the boundary catches; the next mount calls factory anew.
throw error;
}));
}
3. Reset the boundary by remounting the lazy subtree with a key
Bump a key on reset so React discards the old lazy instance and its cached promise, forcing a genuine re-import:
// SafeLazyRoute.jsx — key-based remount on retry
import { Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { lazyWithRetry } from './lazyWithRetry';
const Dashboard = lazyWithRetry(() => import('./Dashboard'));
export default function SafeLazyRoute() {
const [attemptKey, setAttemptKey] = useState(0);
return (
<ErrorBoundary
FallbackComponent={ChunkErrorFallback}
// Bumping the key remounts the subtree, clearing the cached failure.
onReset={() => setAttemptKey((k) => k + 1)}
>
<div key={attemptKey}>
<Suspense fallback={<p>Loading…</p>}>
<Dashboard />
</Suspense>
</div>
</ErrorBoundary>
);
}
The onReset callback fires whenever resetErrorBoundary is called, and bumping the state that feeds key is what tears down the errored subtree. React sees a different key on the wrapping div, unmounts the old Suspense and lazy nodes, and mounts brand-new ones. On that fresh mount the retryable factory runs a second time, issuing a genuinely new network request rather than replaying the cached rejection.
4. Force a hard reload when a stale deploy keeps failing
If the same chunk 404s twice, the client is running against an outdated index.html; a soft remount will keep hitting the same missing filename. Reload the page instead so the browser fetches the new asset manifest and its current content hashes:
// ChunkErrorFallback.jsx — escalate to a full reload after repeated failure
import { useRef } from 'react';
export function ChunkErrorFallback({ resetErrorBoundary }) {
const tries = useRef(0);
const handleRetry = () => {
tries.current += 1;
// After a second failure, the asset manifest is stale — reload fully.
if (tries.current >= 2) window.location.reload();
else resetErrorBoundary();
};
return (
<div role="alert">
<p>Could not load this section.</p>
<button onClick={handleRetry}>Retry</button>
</div>
);
}
Verification
Simulate a chunk that fails on first import and succeeds on retry, then assert the fallback shows and clears. This test uses a factory whose behavior flips between calls:
// SafeLazyRoute.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Suspense, lazy, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
// Import fails the first time, resolves the second time.
let calls = 0;
const factory = () =>
new Promise((resolve, reject) => {
calls += 1;
if (calls === 1) reject(new Error('Failed to fetch dynamically imported module'));
else resolve({ default: () => <p>Dashboard ready</p> });
});
function Harness() {
const [key, setKey] = useState(0);
const Lazy = lazy(factory); // new lazy per remount picks up a fresh promise
return (
<ErrorBoundary
onReset={() => setKey((k) => k + 1)}
fallbackRender={({ resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>Retry</button>
)}
>
<div key={key}>
<Suspense fallback={<p>Loading…</p>}>
<Lazy />
</Suspense>
</div>
</ErrorBoundary>
);
}
test('a failed chunk load recovers on retry', async () => {
const user = userEvent.setup();
render(<Harness />);
// First import rejected — the boundary fallback is shown.
expect(await screen.findByRole('button', { name: 'Retry' })).toBeInTheDocument();
// Retry remounts and the second import resolves.
await user.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText('Dashboard ready')).toBeInTheDocument();
});
The two assertions prove both halves of the fix: the boundary catches the rejected import (the Retry button appears), and the key-based remount produces a fresh promise that resolves (the dashboard content renders).
Edge Cases & Gotchas
-
The boundary must be outside
<Suspense>, never inside it. An error boundary placed as a child of Suspense will not catch the lazy import’s rejection, because the throw originates from the Suspense boundary itself resolving its child. Keep the order<ErrorBoundary><Suspense>…</Suspense></ErrorBoundary>. -
resetErrorBoundaryalone does not re-import. Without the key-based remount from step 3, resetting only clears the boundary’shasErrorflag; React re-reads the same cached rejected promise fromReact.lazyand immediately errors again. The remount is what discards that cache. -
Preloaded chunks can mask the problem in development. Vite and webpack dev servers serve chunks from memory, so imports almost never 404 locally. Test the failure path by throttling to offline in DevTools mid-navigation, or by pointing the import at a deleted filename in a preview build.
-
Distinguish chunk-load errors from real render errors. A boundary around a lazy route also catches genuine bugs inside
Dashboard. Inspecterror.messageforFailed to fetch dynamically imported moduleorLoading chunkbefore offering “Retry” — retrying a real logic error just loops. Send the render errors to your telemetry backend instead.
FAQ
Why doesn’t the Suspense fallback show when the chunk fails?
Suspense has exactly two states for the promise React.lazy gives it: pending and fulfilled. While pending it renders fallback; when fulfilled it renders the module. A rejected promise is neither — it is an error, and React deliberately routes errors to the nearest error boundary rather than to a Suspense fallback. That separation is intentional: “still loading” and “failed to load” are different user experiences that deserve different UI, so you provide the spinner via Suspense and the failure UI via the boundary.
Do I need a separate boundary for every lazy route?
Not necessarily, but scope matters. One boundary around your whole <Routes> will catch any chunk failure, but a retry then remounts every route. Placing a boundary just inside each lazy route keeps the retry localized to the section that failed, so the navigation shell and other loaded content stay interactive. For per-route reset behavior across navigations, combine this with the technique in Resetting React Error Boundaries on Route Change.
How do I stop old clients from ever hitting a stale chunk?
You cannot fully prevent it — a user with a tab open across a deploy will always request the previous manifest’s filenames. The mitigation is graceful recovery: catch the failure with the boundary, and on repeated failure call window.location.reload() (step 4) so the browser fetches the fresh index.html and its current asset hashes. Pairing that with long-lived, content-hashed filenames means the reload is the only moment the user notices a deploy happened.
Related
- Implementing React Error Boundaries for Production — the parent guide covering the full boundary lifecycle and telemetry.
- Resetting React Error Boundaries on Route Change — reset boundaries stuck in an error state after navigation.
- How to Gracefully Degrade UI on Component Failure — design fallbacks that keep the rest of the app usable.
- Core JavaScript Error Handling & Boundaries — the reference section this how-to belongs to.