Skip to content

Error Boundaries and Suspense

Aman Kumar Singh8 min read
Part 14 of 50From the React & Next.js Complete Guide series
Error Boundaries and Suspense — article by Aman Kumar Singh

In Compound Components and Slots we looked at how to give component consumers flexibility without exposing internal structure. This post moves from composition to resilience: what happens when a component tree fails to render, or needs to wait on something before it can render at all. This is part 6 of the React & Next.js Complete Guide series.

Error boundaries and Suspense solve two different problems. They get talked about as one topic because React's rendering model treats them similarly under the hood. An error boundary catches a render-time exception and shows a fallback instead of a blank screen. Suspense pauses rendering until a promise resolves and shows a fallback in the meantime. Neither one is optional in a production SaaS app once you have data fetching, code splitting, and enough users hitting edge cases you didn't test for.

I want to cover both in enough depth that you can reason about placement, not just copy a pattern. Where you put a boundary changes what recovers gracefully and what takes the whole page down with it.

Why error boundaries matter more than they look

Before error boundaries existed as a stable pattern, an uncaught exception in a component's render method would unmount the entire React tree. Not the component, the entire app. React still does this by default for any error that escapes a boundary, which is a deliberate choice: rendering a UI built from state that threw an exception is considered more dangerous than showing nothing.

That default is correct for React as a library, but it is a bad experience for your users if you never opt in to something better. A single malformed API response causing a null pointer somewhere in a dashboard widget shouldn't take down the billing page next to it. An error boundary lets you contain the blast radius to the smallest UI unit that makes sense, usually a card, a route segment, or a modal.

The critical thing to understand: error boundaries only catch errors during rendering, in lifecycle methods, and in constructors of the components below them in the tree. They do not catch errors in:

  • Event handlers (use a regular try/catch there)
  • Asynchronous code (setTimeout, fetch callbacks, promise rejections)
  • Server-side rendering itself (Next.js has its own error handling for that, covered in the app router article)
  • Errors thrown in the boundary component itself

This is the single most common misunderstanding I see in code review. Someone wraps a component in an error boundary and expects it to catch a rejected fetch promise inside a click handler, and it doesn't, because that error never happens during render.

Building a production error boundary

Error boundaries still require a class component because the relevant lifecycle methods, getDerivedStateFromError and componentDidCatch, don't have hook equivalents. This is one of the few places in modern React where you're required to reach for a class instead of hooks. It just is what it is.

import React from "react";

interface ErrorBoundaryProps {
  fallback: (error: Error, reset: () => void) => React.ReactNode;
  onError?: (error: Error, info: React.ErrorInfo) => void;
  children: React.ReactNode;
}

interface ErrorBoundaryState {
  error: Error | null;
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    this.props.onError?.(error, info);
  }

  reset = () => {
    this.setState({ error: null });
  };

  render() {
    if (this.state.error) {
      return this.props.fallback(this.state.error, this.reset);
    }
    return this.props.children;
  }
}

The reset callback matters more than it looks. Without a way to clear the error state, a boundary that catches once stays broken forever, even if the underlying data that caused the failure changes. In practice you'd wire reset to something meaningful, like refetching the data that failed, not just clearing local state and re-rendering the same broken props.

Usage in a dashboard widget:

function RevenueWidget() {
  return (
    <ErrorBoundary
      onError={(error, info) => reportToMonitoring(error, info)}
      fallback={(error, reset) => (
        <div className="widget-error">
          <p>Couldn't load revenue data.</p>
          <button onClick={reset}>Retry</button>
        </div>
      )}
    >
      <RevenueChart />
    </ErrorBoundary>
  );
}

onError is where you hook into your logging pipeline. Send the error and component stack to whatever you use for structured logging or error tracking. Don't just console.error and move on; a caught render error is still a bug and someone needs to see it in production, not just have it silently swallowed by a nice fallback UI.

Suspense: pausing render, not catching failure

Suspense is a mechanism for a component to tell React "I'm not ready yet" by throwing a promise instead of a value. React catches that thrown promise, shows the nearest fallback, and re-renders the subtree once the promise resolves. It has nothing to do with error handling directly, though the two compose well together because a Suspense boundary is usually paired with an error boundary right above it to catch the case where the promise rejects instead of resolving.

The two most common uses of Suspense in a real app are:

  1. Code splitting with React.lazy, which has been stable and safe to rely on for years.
  2. Data fetching with a library or framework that implements the Suspense contract, like React Query's useSuspenseQuery, Relay, or the Next.js App Router's server components.

Rolling your own Suspense-compatible data fetcher by throwing raw promises is possible but brittle in practice, mostly around request deduplication and cache invalidation. I'd defer that complexity and use a library that already solved it unless you have a very specific reason not to.

import { lazy, Suspense } from "react";
import { ErrorBoundary } from "./ErrorBoundary";

const BillingSettings = lazy(() => import("./BillingSettings"));

function SettingsPage() {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <SettingsErrorFallback error={error} onRetry={reset} />
      )}
    >
      <Suspense fallback={<SettingsSkeleton />}>
        <BillingSettings />
      </Suspense>
    </ErrorBoundary>
  );
}

The ordering here is intentional. The error boundary wraps the Suspense boundary, not the other way around, so a rejected import or a data fetch failure inside BillingSettings gets caught by the boundary above rather than leaving Suspense stuck showing a fallback for a promise that will never resolve successfully.

Placement strategy: where boundaries actually go

The temptation is to put one error boundary and one Suspense boundary at the root of the app and call it done. This works, technically, but it means any failure anywhere degrades the entire application to a single fallback screen. That's rarely what you want for a SaaS product where a user is often looking at multiple independent widgets on one page.

A better default is boundary-per-independent-unit:

  • Route level: catch navigation failures and show a route-specific error page.
  • Widget or card level: a failed analytics chart shouldn't blank out the invoice list next to it.
  • Third-party integration level: anything calling an external API you don't control (payment provider embeds, map widgets, chat plugins) gets its own boundary, because those are the components most likely to throw for reasons outside your codebase.

The tradeoff is more boilerplate. Every boundary needs a sensible fallback UI, and writing a good fallback (one that explains what failed and offers a retry, not just "Something went wrong") takes actual design thought. I've found the practical middle ground is to build one reusable ErrorBoundary and one reusable Suspense fallback component per UI pattern (card, page, modal) and reuse those consistently, rather than hand-rolling a fallback per feature.

Suspense for images and transitions

Two other Suspense-adjacent patterns worth knowing about, since they come up in real product work:

useTransition lets you mark a state update as non-urgent, so React can keep showing the old UI (rather than the nearest Suspense fallback) while the new one is being prepared. This matters for things like tab switches where you don't want a full-screen skeleton flash for every click:

import { useTransition } from "react";

function TabbedReport() {
  const [isPending, startTransition] = useTransition();
  const [activeTab, setActiveTab] = React.useState("overview");

  const selectTab = (tab: string) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };

  return (
    <div style={{ opacity: isPending ? 0.6 : 1 }}>
      <TabContent tab={activeTab} />
    </div>
  );
}

Without startTransition, switching tabs on a Suspense-wrapped component would show the fallback again on every click, which reads as flickery and cheap. With it, React keeps the previous tab's content visible (dimmed, in this example) until the new content is ready.

Production pitfalls

A few things that trip teams up once this lands in a real codebase:

Error boundaries don't reset automatically when props change. If you navigate from one broken record to a different, working one, the boundary stays in its error state unless you either unmount and remount it (commonly done by keying the boundary on a route param) or explicitly call reset. Keying on the route id is usually simplest:

<ErrorBoundary key={recordId} fallback={renderFallback}>
  <RecordDetail id={recordId} />
</ErrorBoundary>

Silent swallowing is a real risk. A fallback that looks polished can hide a real production incident from your team if onError isn't wired to actual alerting. Treat a caught error the same as an uncaught one for monitoring purposes; the only difference should be what the user sees.

Suspense fallbacks that never resolve usually mean a promise is being recreated on every render instead of cached, which causes an infinite loading state that looks like a bug in Suspense but is actually a bug in the data-fetching layer. This is the strongest argument for using a maintained library instead of hand-rolling the promise-throwing logic yourself.

Key takeaways

  • Error boundaries only catch render-time errors in the tree below them; event handlers and async code need their own try/catch.
  • A class component is still required for a custom error boundary; there's no hook equivalent for `getDerivedStateFromError` or `componentDidCatch`.
  • Suspense pauses rendering for a thrown promise and has nothing to do with catching errors; pair it with an error boundary for the rejection case.
  • Place boundaries per independent UI unit (widget, route, third-party embed) rather than one boundary for the whole app, so failures stay contained.
  • Key an error boundary on a stable identifier (like a record id) if you need it to reset automatically when navigating between records.
  • Always wire `onError` and rejected-promise paths to real monitoring; a nice fallback UI shouldn't come at the cost of visibility into production failures.

Frequently asked questions

Do I need both an error boundary and a try/catch block?

Yes, they cover different failure modes. Error boundaries catch exceptions thrown during rendering; try/catch is still required inside event handlers, async functions, and effects, since none of those run during React's render phase.

Can I use error boundaries with functional components?

Your components under the boundary can be functional; the boundary itself must be a class component, because `getDerivedStateFromError` and `componentDidCatch` have no hook equivalent as of the current React API.

Why does my Suspense fallback show forever even though the data loaded?

This almost always means the promise your component throws is being recreated on every render instead of reused from a cache. React needs the same promise reference to know it resolved; a new promise each render looks like a fresh, unresolved request every time.

Should I put one error boundary around my entire app?

A single top-level boundary is a reasonable last resort, but relying on it alone means any isolated failure degrades the whole page. Add boundaries around independent units, widgets, route segments, third-party embeds, so one failure doesn't take out unrelated UI.

How is Suspense different in the Next.js App Router?

The App Router integrates Suspense with server components and streaming, so a `loading.tsx` file effectively becomes a Suspense fallback for that route segment during server rendering, not just client-side lazy loading. That integration is covered in more depth in the App Router article in this series.

Does an error boundary catch errors during server-side rendering?

Not directly. SSR errors are handled by the framework's own server error handling; a client error boundary only takes effect once React is running in the browser, or in frameworks like Next.js, when hydration or a subsequent client render throws.

Related articles

Error Handling and Loading States — Aman Kumar Singh
Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.