Skip to content
Codeloom
React

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.

·6 min read · By Codeloom
Advanced 11 min read

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.