React Suspense Error Boundaries: Recovery Patterns
Errors inside Suspense don't always reach your ErrorBoundary. Master propagation rules, SSR behavior, and recovery patterns for resilient React apps.
Errors inside Suspense trees don't behave like normal React errors. They can bypass the nearest error boundary, reset unexpectedly, or leave your app in a state where recovery seems impossible. Understanding propagation rules is essential for resilient data-fetching UIs.
The Basic Model: Error Boundaries Catch Suspense Errors
When a component inside <Suspense> throws an error (not a promise), React
propagates it to the nearest ErrorBoundary:
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<DataComponent /> {/* throws Error */}
</Suspense>
</ErrorBoundary>
If DataComponent throws during render, the error propagates up past Suspense
to the ErrorBoundary. Suspense only catches promises, not errors.
The SSR Twist: Different Behavior on Server vs Client
Server-side rendering changes propagation rules significantly:
// Server: Component throws during SSR
<Suspense fallback={<Skeleton />}>
<AsyncComponent /> {/* throws on server */}
</Suspense>On the server, if a component throws, React includes the Suspense fallback in the HTML (not the error boundary's fallback). The client then attempts to render the same component. If it errors on the client too, then the ErrorBoundary catches it.
// Server behavior:
// 1. AsyncComponent throws
// 2. Server sends Suspense fallback (Skeleton) in HTML
// 3. Client hydrates, retries AsyncComponent
// 4. If client also throws → ErrorBoundary catchesThis two-phase error handling means your error might only appear after hydration, and the server logs may not reflect what the user sees.
Resetting Error Boundaries: The Key Challenge
Once an ErrorBoundary catches an error, its subtree is replaced with the error fallback. But how do you recover? The boundary needs to reset and re-render the children.
Pattern 1: Key-Based Reset
function App() {
const [errorKey, setErrorKey] = useState(0);
return (
<ErrorBoundary
key={errorKey}
onReset={() => setErrorKey(k => k + 1)}
fallback={({ resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>Retry</button>
)}
>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
Changing the key forces React to unmount and remount the ErrorBoundary,
clearing its error state. Libraries like react-error-boundary provide
resetErrorBoundary helpers.
Pattern 2: State-Based Reset with resetKeys
<ErrorBoundary
resetKeys={[userId, query]}
onResetKeysChange={() => {
// Clear cache, reset state, etc.
}}
>
<UserData userId={userId} />
</ErrorBoundary>
When any value in resetKeys changes, the boundary automatically resets.
Useful for data-fetching scenarios where new inputs should clear previous errors.
Suspense + Transitions: Errors Stay Hidden
startTransition prevents Suspense from showing fallbacks during updates.
But this also affects error propagation:
function SearchResults() {
const [query, setQuery] = useState('');
return (
<>
<input onChange={e => {
startTransition(() => setQuery(e.target.value));
}} />
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<Results query={query} />
</Suspense>
</ErrorBoundary>
</>
);
}
If Results throws during a transition, React keeps showing the
previous content rather than immediately switching to the error boundary.
The error eventually propagates, but timing surprises users expecting instant feedback.
The "use" Hook and Promise Rejection
React's use hook reads promises during render. Rejected promises throw:
function DataComponent({ dataPromise }) {
const data = use(dataPromise); // throws if rejected
return <div>{data.name}</div>;
}The thrown error propagates like any render error. But there's a subtle issue:
// ❌ Bug: New promise on every render
function Parent() {
return (
<Suspense fallback={<Loading />}>
<DataComponent dataPromise={fetchData()} />
</Suspense>
);
}
If fetchData() returns a new promise each render, Suspense never settles.
If that promise rejects, you might see infinite error loops:
- Promise rejects → error thrown
- ErrorBoundary catches, re-renders parent
- Parent creates new promise → suspends again
- New promise rejects → repeat
Cache promises at a stable reference (React Query, SWR, or a cache() wrapper).
Nested Boundaries: Granular Recovery
Place error boundaries strategically for isolated recovery:
<ErrorBoundary fallback={<PageError />}>
<Header />
<Suspense fallback={<MainSkeleton />}>
<ErrorBoundary fallback={<ContentError />}>
<MainContent />
</ErrorBoundary>
</Suspense>
<ErrorBoundary fallback={<SidebarError />}>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</ErrorBoundary>
</ErrorBoundary>
Sidebar errors don't take down MainContent. Each section
can retry independently. The outer boundary catches errors that escape inner ones.
ErrorBoundary Inside vs Outside Suspense
// Option A: ErrorBoundary outside Suspense
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Data />
</Suspense>
</ErrorBoundary>
// Option B: ErrorBoundary inside Suspense
<Suspense fallback={<Loading />}>
<ErrorBoundary>
<Data />
</ErrorBoundary>
</Suspense>Option A: Error replaces the entire Suspense region. Option B: Error shows inside the Suspense container; other suspended siblings can still resolve. Choose based on your UI recovery needs.
TanStack Query / React Query Integration
When using suspense: true with React Query, errors behave differently:
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
suspense: true,
useErrorBoundary: true, // Required for error propagation
});
Without useErrorBoundary: true, React Query catches errors internally
and returns them via the error property instead of throwing.
Reset with QueryErrorResetBoundary:
import { QueryErrorResetBoundary } from '@tanstack/react-query';
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} fallback={...}>
<Suspense>
<UserData />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
The reset function clears the query cache for failed queries,
allowing a clean retry when the ErrorBoundary resets.
Event Handlers: Errors Don't Propagate
ErrorBoundaries only catch errors during render. Event handlers are outside the React render cycle:
function Form() {
const handleSubmit = async () => {
throw new Error('Submit failed'); // ❌ Not caught by ErrorBoundary
};
return <button onClick={handleSubmit}>Submit</button>;
}Use local state for event handler errors:
function Form() {
const [error, setError] = useState(null);
if (error) throw error; // Re-throw during render → caught
const handleSubmit = async () => {
try {
await submit();
} catch (e) {
setError(e); // Triggers re-render → throws
}
};
return <button onClick={handleSubmit}>Submit</button>;
}Production Recovery Checklist
- Cache promises to prevent infinite error loops with
use - Use resetKeys to auto-reset on input changes
- Nest boundaries for isolated, granular recovery
- Handle SSR knowing errors may only surface after hydration
- Re-throw event errors via state to reach boundaries
- Integrate with data libraries using their boundary helpers
Key Takeaways
- Suspense catches promises; ErrorBoundaries catch thrown errors—both needed for resilient UIs
- SSR errors show Suspense fallback first, then propagate to ErrorBoundary on client hydration
- Use
keyorresetKeysto reset error boundaries and retry rendering - Transitions can delay error visibility by keeping previous content on screen
- Event handler errors require manual re-throwing via state to reach boundaries
- React Query/TanStack Query need explicit
useErrorBoundary: truefor propagation
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement