Apr 20, 2026

React Suspense and Error Boundaries: A Complete Practical Guide

Learn how React Suspense and Error Boundaries work together to handle async loading states and runtime errors gracefully, with practical code examples for Next.js apps.

React
Next.js
Frontend
Best Practices
Performance

9 min read

React Suspense and Error Boundaries: A Complete Practical Guide

Every React application eventually has to answer two questions: what do users see while data loads, and what happens when something breaks? For years the answers were scattered across isLoading state flags, try/catch blocks, and ad-hoc error UI sprinkled throughout components. React Suspense and Error Boundaries give a unified, declarative answer to both — define fallback UIs at the right level in the tree, and let React coordinate the rest.

This guide covers how Suspense works for async rendering, how to write error boundaries that are actually useful, how the two compose together, and the patterns that make them production-ready in a Next.js App Router project. For the performance side of data fetching that pairs with Suspense, see Next.js Caching and Revalidation Guide. For TypeScript patterns that make boundary props and fallback components type-safe, see React TypeScript Patterns Every Developer Should Know.

What Suspense Actually Does

Suspense is not a data-fetching library. It is a rendering coordination mechanism. When a component inside a <Suspense> boundary signals that it is not ready — by throwing a Promise — React pauses rendering that subtree and renders the fallback prop instead. When the Promise resolves, React re-renders the subtree.

import { Suspense } from "react";
import { UserProfile } from "@/components/UserProfile";
import { ProfileSkeleton } from "@/components/ProfileSkeleton";

export default function ProfilePage() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId="keval" />
    </Suspense>
  );
}

In the Next.js App Router, Server Components that await data automatically integrate with Suspense. Wrapping a slow component in <Suspense> lets the page shell render immediately while the slow component streams in, which improves both perceived performance and Core Web Vitals scores.

Writing a Useful Error Boundary

Error boundaries are class components — the only place where class components remain essential in modern React. They catch runtime errors thrown during rendering, in lifecycle methods, and in constructors of child components. They do not catch errors in event handlers or async code outside render (use try/catch there).

Here is a reusable, production-ready error boundary:

"use client";

import { Component, type ReactNode, type ErrorInfo } from "react";

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

interface ErrorBoundaryState {
  error: Error | null;
}

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

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

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info);
    // Log to your error monitoring service here
    // e.g., Sentry.captureException(error, { extra: info });
  }

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

  render() {
    const { error } = this.state;
    const { fallback, children } = this.props;

    if (error) {
      return typeof fallback === "function"
        ? fallback(error, this.reset)
        : fallback;
    }

    return children;
  }
}

The function-as-fallback pattern passes both the caught error and a reset callback to the UI, so users can recover without a full page reload:

<ErrorBoundary
  fallback={(error, reset) => (
    <div className="error-card">
      <h2>Something went wrong</h2>
      <p className="text-sm text-muted">{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )}
  onError={(error, info) => console.error("Boundary caught:", error, info)}
>
  <RiskyComponent />
</ErrorBoundary>

Composing Suspense and Error Boundaries Together

Suspense handles the pending state; an error boundary handles the error state. They are designed to work at the same level in the tree:

"use client";

import { Suspense } from "react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { DataTable } from "@/components/DataTable";
import { TableSkeleton } from "@/components/TableSkeleton";

export function DataSection() {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <div className="border border-destructive rounded-md p-4">
          <p className="font-medium">Failed to load data</p>
          <p className="text-sm text-muted-foreground">{error.message}</p>
          <button
            className="mt-2 text-sm underline"
            onClick={reset}
          >
            Retry
          </button>
        </div>
      )}
    >
      <Suspense fallback={<TableSkeleton rows={5} />}>
        <DataTable />
      </Suspense>
    </ErrorBoundary>
  );
}

The nesting order matters: ErrorBoundary wraps Suspense so that errors thrown after data loads (inside DataTable) are also caught. If you reverse them, errors thrown during loading would be invisible to the error boundary.

Granularity: Where to Place Boundaries

A single top-level error boundary keeps the entire app from crashing, but it gives users a poor experience — one broken widget takes down the whole page. The better approach is to align boundaries with logical feature sections:

// app/dashboard/page.tsx (Server Component)
import { Suspense } from "react";
import { ErrorBoundary } from "@/components/ErrorBoundary";

export default function Dashboard() {
  return (
    <div className="grid grid-cols-12 gap-4">
      {/* Sidebar loads independently */}
      <aside className="col-span-3">
        <ErrorBoundary fallback={<SidebarError />}>
          <Suspense fallback={<SidebarSkeleton />}>
            <Sidebar />
          </Suspense>
        </ErrorBoundary>
      </aside>

      {/* Main content loads independently */}
      <main className="col-span-9 space-y-4">
        <ErrorBoundary fallback={<MetricsError />}>
          <Suspense fallback={<MetricsSkeleton />}>
            <MetricsGrid />
          </Suspense>
        </ErrorBoundary>

        <ErrorBoundary fallback={<FeedError />}>
          <Suspense fallback={<FeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </ErrorBoundary>
      </main>
    </div>
  );
}

Each section can fail independently. A broken activity feed does not prevent the sidebar and metrics from rendering. This pattern is particularly effective in the Next.js App Router, where each Suspense boundary maps to a streaming chunk sent to the browser.

react-error-boundary: A Production-Ready Library

If writing class components feels out of place in a hooks-first codebase, the react-error-boundary package offers a well-maintained functional alternative:

npm install react-error-boundary
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";

function FallbackComponent({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre className="text-sm">{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

// Usage
<ErrorBoundary
  FallbackComponent={FallbackComponent}
  onReset={() => {
    // Optionally clear caches or reset global state here
  }}
>
  <ComponentThatMayError />
</ErrorBoundary>

The useErrorBoundary hook lets any component inside a boundary programmatically trigger it — useful when an error originates in an event handler rather than during rendering:

function SubmitButton() {
  const { showBoundary } = useErrorBoundary();

  async function handleSubmit() {
    try {
      await submitForm();
    } catch (err) {
      // Propagate to the nearest error boundary
      showBoundary(err);
    }
  }

  return <button onClick={handleSubmit}>Submit</button>;
}

Suspense with use() in React 19

React 19 introduced the use() hook, which suspends on a Promise directly inside a functional component without a third-party library:

import { use, Suspense } from "react";

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`, { next: { revalidate: 60 } });
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json();
}

// Pass a Promise as a prop — created outside the component
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // Suspends until resolved
  return <div>{user.name}</div>;
}

export default function Page() {
  const userPromise = fetchUser("keval");
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

use() also works for reading context conditionally, something plain useContext cannot do. This makes it a versatile primitive for async-first component patterns in modern React.

Skeleton Screens: Making Fallbacks Feel Instant

The react suspense fallback loading experience lives or dies on the quality of the skeleton. A blank white area feels broken. A well-shaped skeleton communicates that real content is on its way:

// components/ArticleCardSkeleton.tsx
export function ArticleCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4 space-y-3">
      <div className="h-5 bg-muted rounded w-3/4" />
      <div className="h-4 bg-muted rounded w-full" />
      <div className="h-4 bg-muted rounded w-5/6" />
      <div className="flex gap-2 pt-2">
        <div className="h-6 w-16 bg-muted rounded-full" />
        <div className="h-6 w-20 bg-muted rounded-full" />
      </div>
    </div>
  );
}

Match the skeleton's dimensions and layout to the real component as closely as possible. The CSS animate-pulse utility from Tailwind adds a subtle shimmer that signals loading without feeling jarring.

Error Logging and Monitoring

componentDidCatch and the onError prop are the right places to send errors to an observability platform. In a Next.js project:

// lib/monitoring.ts
export function reportError(error: Error, context?: Record<string, unknown>) {
  if (process.env.NODE_ENV === "production") {
    // Replace with your actual monitoring SDK call
    // Sentry.captureException(error, { extra: context });
    fetch("/api/log-error", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: error.message, stack: error.stack, ...context }),
    }).catch(() => {
      // Never throw from error reporting
    });
  } else {
    console.error("[ErrorBoundary]", error, context);
  }
}
<ErrorBoundary
  onError={(error, info) =>
    reportError(error, { componentStack: info.componentStack })
  }
  fallback={<GenericErrorFallback />}
>
  {children}
</ErrorBoundary>

Keeping error reporting fire-and-forget (never throwing) and silent in development console-only mode avoids noisy alerts during local development while ensuring errors are captured in production.

Common Pitfalls

Forgetting "use client" on error boundaries. Error boundaries are class components; they must be Client Components in the App Router. Add "use client" at the top of the file.

Over-broad boundaries. A single boundary for the entire app is better than nothing, but granular boundaries give users partial degradation rather than a full crash page.

Not resetting on route change. In Next.js, a user navigating away and back will still see the error fallback unless you reset the boundary. Pass key={pathname} to ErrorBoundary to reset it automatically on navigation:

import { usePathname } from "next/navigation";

function RouteBoundary({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  return (
    <ErrorBoundary key={pathname} fallback={<GenericError />}>
      {children}
    </ErrorBoundary>
  );
}

Swallowing async errors in event handlers. Errors thrown in onClick, onSubmit, and similar handlers do not reach error boundaries automatically. Use useErrorBoundary's showBoundary or manage that error state locally.

Putting It Together

React Suspense and Error Boundaries address the two failure modes every React app has to handle. Suspense turns async-pending into a declarative loading UI keyed to the component tree rather than imperative state. Error boundaries make runtime failures explicit and recoverable, giving users a path forward rather than a broken page.

The composition pattern — <ErrorBoundary> wrapping <Suspense> at each logical section boundary — gives the cleanest separation: Suspense owns loading, the error boundary owns failure, and your actual components stay free of either concern. Add a quality skeleton fallback, wire up error reporting, and reset on navigation, and you have a resilient loading and error architecture that scales with the application.

For further hardening, Secure API Route Patterns in Next.js covers how to structure API responses so errors surfaced to error boundaries expose the minimum information needed — important for user-facing react error boundary example messages that should never leak server internals.

Related Reading