React Error Boundaries in Production: Recovery and Monitoring
Production-grade Error Boundary patterns for React apps including granular placement, retry logic, error reporting, and integration with Suspense and routing.
What you'll learn
- ✓How to place Error Boundaries for maximum resilience
- ✓Building retry and reset functionality into boundaries
- ✓Integrating error reporting with monitoring services
- ✓Combining Error Boundaries with Suspense and routing
- ✓Patterns for graceful degradation in complex UIs
Prerequisites
None — this post is self-contained.
Error Boundaries prevent a single broken component from taking down your entire app. The basics are simple: wrap a subtree, catch errors, show a fallback. But in production, the questions get harder. Where do you place them? How do you recover? How do you report errors without drowning in noise? This article covers the patterns that matter when real users hit real errors.
Boundary placement strategy
The most common mistake is placing a single Error Boundary at the root and calling it done. That catches everything but recovers nothing. The entire app shows a fallback for any error, no matter how minor.
A better strategy uses three tiers of boundaries.
Tier 1: Root boundary. Catches catastrophic errors that escape everything else. Shows a full-page error with a reload button. This is the last resort.
Tier 2: Route boundaries. Each route or page gets its own boundary. If one page breaks, the navigation and layout remain functional. The user can navigate away.
Tier 3: Feature boundaries. Individual widgets, cards, or sections that might fail independently. A broken chart does not take down the sidebar.
// Root layout
function App() {
return (
<RootErrorBoundary>
<Layout>
<Navigation />
<main>
{/* Route-level boundary */}
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
</Layout>
</RootErrorBoundary>
);
}
// Inside a page
function DashboardPage() {
return (
<div className="dashboard">
<FeatureErrorBoundary name="revenue-chart">
<RevenueChart />
</FeatureErrorBoundary>
<FeatureErrorBoundary name="user-table">
<UserTable />
</FeatureErrorBoundary>
<FeatureErrorBoundary name="activity-feed">
<ActivityFeed />
</FeatureErrorBoundary>
</div>
);
}
A production-ready Error Boundary
The built-in class component API is minimal. A production boundary needs retry logic, error reporting, and contextual fallbacks.
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = {
children: ReactNode;
fallback?: ReactNode | ((props: { error: Error; retry: () => void }) => ReactNode);
onError?: (error: Error, errorInfo: ErrorInfo) => void;
name?: string;
};
type State = {
error: Error | null;
};
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
retry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
const { fallback } = this.props;
if (typeof fallback === 'function') {
return fallback({ error: this.state.error, retry: this.retry });
}
return fallback ?? <DefaultFallback error={this.state.error} retry={this.retry} />;
}
return this.props.children;
}
}
function DefaultFallback({ error, retry }: { error: Error; retry: () => void }) {
return (
<div role="alert" className="error-fallback">
<h3>Something went wrong</h3>
<p>{error.message}</p>
<button onClick={retry}>Try again</button>
</div>
);
}
This boundary accepts a render function as its fallback, which receives the error and a retry function. The retry clears the error state, which causes React to attempt rendering the children again.
Error reporting without noise
In production, you need to report errors to a monitoring service. But not every error deserves an alert.
function reportError(error: Error, errorInfo: ErrorInfo, context: string) {
// Skip known, user-facing errors
if (error instanceof UserFacingError) {
return;
}
// Skip network errors that are likely transient
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
reportToMonitoring({
error,
severity: 'warning',
context,
componentStack: errorInfo.componentStack,
});
return;
}
// Everything else is a genuine bug
reportToMonitoring({
error,
severity: 'error',
context,
componentStack: errorInfo.componentStack,
});
}
The name prop on the boundary provides context. When you see “error in revenue-chart” in your monitoring dashboard, you know exactly where to look.
<ErrorBoundary
name="revenue-chart"
onError={(error, info) => reportError(error, info, 'revenue-chart')}
>
<RevenueChart />
</ErrorBoundary>
Retry patterns
A simple retry clears the error state and re-renders the children. But if the error is deterministic (a coding bug, not a network issue), the retry will fail immediately and create an infinite loop of error and retry.
Guard against this with a retry limit:
export class RetryBoundary extends Component<Props, RetryState> {
state: RetryState = { error: null, retryCount: 0 };
maxRetries = 3;
static getDerivedStateFromError(error: Error): Partial<RetryState> {
return { error };
}
retry = () => {
this.setState((prev) => ({
error: null,
retryCount: prev.retryCount + 1,
}));
};
render() {
if (this.state.error) {
if (this.state.retryCount >= this.maxRetries) {
return (
<div role="alert">
<p>This section is unavailable. Please reload the page.</p>
</div>
);
}
return (
<div role="alert">
<p>Something went wrong.</p>
<button onClick={this.retry}>
Retry ({this.maxRetries - this.state.retryCount} attempts remaining)
</button>
</div>
);
}
return this.props.children;
}
}
For network errors, add a delay between retries with exponential backoff:
retryWithDelay = () => {
const delay = Math.min(1000 * 2 ** this.state.retryCount, 10000);
setTimeout(() => {
this.setState((prev) => ({
error: null,
retryCount: prev.retryCount + 1,
}));
}, delay);
};
Combining with Suspense
Error Boundaries and Suspense boundaries work together. The Error Boundary catches errors; the Suspense boundary catches loading states. Order matters: the Error Boundary should wrap the Suspense boundary so it catches errors thrown during async rendering.
function ResilientAsyncSection({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={({ error, retry }) => (
<div role="alert">
<p>Failed to load: {error.message}</p>
<button onClick={retry}>Retry</button>
</div>
)}
>
<Suspense fallback={<SectionSkeleton />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
When the async component throws during loading, the Error Boundary catches it. The retry clears the error, which causes the Suspense boundary to try loading again.
Resetting on navigation
When a user navigates away from a broken page and comes back, the Error Boundary should reset. Use a key prop tied to the route:
import { useLocation } from 'react-router-dom';
function RouteErrorBoundary({ children }: { children: ReactNode }) {
const location = useLocation();
return (
<ErrorBoundary key={location.pathname}>
{children}
</ErrorBoundary>
);
}
Changing the key unmounts and remounts the boundary, clearing any error state. The user gets a fresh attempt when they navigate back.
Graceful degradation
Not every error needs a visible fallback. Sometimes the best response is to silently hide the broken component and let the rest of the page function normally.
function SilentErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={null}
onError={(error, info) => {
reportToMonitoring({ error, severity: 'warning', componentStack: info.componentStack });
}}
>
{children}
</ErrorBoundary>
);
}
// Usage: optional widgets that can fail silently
<SilentErrorBoundary>
<RecommendedArticles />
</SilentErrorBoundary>
Use this sparingly and only for truly optional content. A recommendation widget can disappear without anyone noticing. A checkout button cannot.
What Error Boundaries do not catch
Error Boundaries catch errors during rendering, lifecycle methods, and constructors. They do not catch errors in event handlers, async code (setTimeout, promises), or server-side rendering.
For event handlers, use regular try/catch. For async operations, catch errors in the promise chain and update state to trigger a re-render that the boundary can catch. For server rendering, use framework-specific error handling.
Understanding these limits prevents false confidence. An Error Boundary does not make your app crash-proof. It makes rendering errors recoverable instead of catastrophic.
Related articles
- React React Compound Components: Flexible APIs Without Prop Drilling
Build flexible, composable React components using the compound components pattern with Context, implicit state sharing, and controlled inversion.
- React React Server Components: Architecture Patterns That Scale
Practical architecture patterns for React Server Components including composition strategies, data flow boundaries, and migration approaches for production apps.
- React React Suspense Orchestration: Advanced Loading Patterns
Master advanced React Suspense patterns including nested boundaries, SuspenseList coordination, transition integration, and skeleton architecture for complex UIs.
- React React Testing Library: Advanced Patterns for Complex UIs
Advanced testing patterns with React Testing Library including custom render wrappers, async flows, portals, context-heavy components, and integration testing.