This page extends the class-component patterns in React Error Boundary Implementation to a rendering model where part of the tree never runs in the browser at all.

The exact problem #

A product page streams three server components: a header, a details section, and a recommendations rail. The recommendations service times out. In a client-only app that would be a caught render error with a clear stack. Here, the failure happened on the server, the response was already streaming, and what the browser receives is a chunk that rejects with a message like An error occurred in the Server Components render. The specific message is omitted in production builds… digest: "3821744098".

Three things go wrong from there. Teams place their boundary outside the Suspense region, so the whole page collapses instead of the rail. They report the digest to their crash dashboard and cannot act on it because the real message lives only in server logs. And they wire a retry button that resets client state, which cannot re-run a server component.

Where the boundaries go #

The rule is one boundary per streamed region, inside the Suspense that region streams into.

// app/product/[id]/page.tsx β€” a server component
export default async function ProductPage({ params }: { params: { id: string } }) {
  return (
    <article>
      <ProductHeader id={params.id} />

      {/* Each streamed region: Suspense for pending, boundary for failed. */}
      <ErrorBoundary fallback={<DetailsUnavailable />} scope="product-details">
        <Suspense fallback={<DetailsSkeleton />}>
          <ProductDetails id={params.id} />
        </Suspense>
      </ErrorBoundary>

      <ErrorBoundary fallback={<RailUnavailable />} scope="recommendations">
        <Suspense fallback={<RailSkeleton />}>
          <Recommendations id={params.id} />
        </Suspense>
      </ErrorBoundary>
    </article>
  );
}

The ordering matters and is easy to get backwards. ErrorBoundary outside, Suspense inside: the boundary must survive while the region is pending so it is still mounted when the chunk rejects. Inverted, a pending state can unmount the boundary and the error escapes to the next one up β€” usually the route.

One boundary per streamed region A page frame contains three stacked regions. The header region is fully rendered. The details region is wrapped in a boundary and a Suspense and renders after streaming. The recommendations region, wrapped the same way, shows a fallback because its chunk rejected. A caption notes the boundary wraps the Suspense, not the other way round. ProductHeader β€” streamed first, no boundary needed above the shell boundary β†’ Suspense ProductDetails rendered boundary caught a rejected chunk "Recommendations unavailable" order matters: <ErrorBoundary> wraps <Suspense> inverted, a pending state can unmount the boundary and the error escapes upward one region fails; the rest of the streamed page is untouched

Capturing the real error server-side #

The digest is a correlation id, not a diagnosis. The full error only exists where it was thrown.

// server/render.ts
import { renderToPipeableStream } from 'react-dom/server';

export function renderRoute(req: Request, res: Response, tree: React.ReactNode) {
  const stream = renderToPipeableStream(tree, {
    bootstrapScripts: ['/client.js'],

    // Fires for every error during the server render, including inside Suspense.
    onError(error, errorInfo) {
      // React derives the digest from the error; compute the same value so the
      // log line can be matched to the client's crash report later.
      const digest = digestOf(error);
      logger.error({
        digest,
        route: req.url,
        message: error instanceof Error ? error.message : String(error),
        stack: error instanceof Error ? error.stack : undefined,
        componentStack: errorInfo?.componentStack,
        requestId: req.headers.get('x-request-id'),
      });
      // Attach so React ships the same digest to the client.
      if (error instanceof Error) (error as { digest?: string }).digest = digest;
    },

    onShellError() {
      // The shell itself failed: nothing streamed, so serve a static error page.
      res.statusCode = 500;
      res.setHeader('content-type', 'text/html');
      res.end(STATIC_ERROR_HTML);
    },
  });

  stream.pipe(res);
}

onShellError is the case teams forget. If the error happens before the shell flushes, there is no streamed HTML to fall back into and no client boundary yet β€” only a static response will do.

React 19 root error options #

On the client, React 19 exposes explicit hooks instead of requiring console interception.

// client.tsx
hydrateRoot(document, <App />, {
  // An error a boundary caught. React still logs it; this is your report hook.
  onCaughtError(error, errorInfo) {
    reportCrash({
      scope: 'react-caught',
      message: messageOf(error),
      digest: (error as { digest?: string }).digest,
      componentStack: errorInfo.componentStack ?? undefined,
    });
  },

  // No boundary caught it β€” the tree above is gone. Highest severity.
  onUncaughtError(error, errorInfo) {
    reportCrash({
      scope: 'react-uncaught',
      severity: 'session',
      message: messageOf(error),
      componentStack: errorInfo.componentStack ?? undefined,
    });
  },

  // React recovered β€” most often a hydration mismatch it patched by re-rendering.
  onRecoverableError(error, errorInfo) {
    reportCrash({
      scope: 'react-recoverable',
      severity: 'local',
      message: messageOf(error),
      componentStack: errorInfo.componentStack ?? undefined,
    });
  },
});

Reporting onRecoverableError is where the value hides. Hydration mismatches that React silently patches cost a full client re-render of the affected subtree and are invisible in normal telemetry β€” the diagnosis path described in Hydration Mismatch & State Recovery.

React 19 root error callbacks and what each one means Three rows. onCaughtError means a boundary handled it, the user sees a fallback, and it is reported at local severity. onUncaughtError means nothing caught it, the tree above is gone, and it is reported at session severity. onRecoverableError means React repaired the render, the user sees nothing wrong, and it is reported as a performance and correctness signal. callback what happened user sees severity onCaughtError a boundary handled it a fallback local onUncaughtError nothing caught it a blank tree session onRecoverableError React repaired the render nothing wrong signal only all three replace the old practice of monkey-patching console.error to capture React failures What the browser receives when one streamed region fails A left-to-right stream of chunks: shell, region one, region two which rejects, and a fallback swap chunk that replaces only region two. A note explains that the shell has already been committed and cannot be taken back. shell details region rail region rejects fallback swap chunk replaces the rail only the shell was already committed β€” this is why a shell failure needs a static 500 instead and why the boundary must live inside the region, not around the whole page

Edge cases #

Case Behaviour Handling
Error before the shell flushes No HTML streamed, no client boundary exists onShellError serves a static 500 page
Error after the shell flushes React streams a fallback swap into the region Region boundary shows its fallback; page keeps working
Server action rejects Surfaces on the client as a rejected promise, not a render throw Handle in the action’s caller; see server action errors
Client component throws inside a server-rendered region Ordinary client boundary semantics Same boundary catches it; distinguish by digest presence
Retry pressed on a server-region fallback Client cannot re-run the server component Re-request the segment (router refresh), not a state reset
Digest missing in development Real messages are shipped in dev builds Do not build triage tooling that assumes a digest exists

Verification #

  1. Throw in one server component. The other regions must still render, the failing region shows its fallback, and the shell is intact.
  2. Match a digest end to end. Copy the digest from the browser console and grep your server log for it; the full message and stack must be there.
  3. Break the shell deliberately. Throw at the top of the route before any Suspense; confirm onShellError fires and a static error page is served with a 500 status.
  4. Force a hydration mismatch. Render a timestamp on the server; confirm onRecoverableError reports it even though the page looks fine.
  5. Test the retry. Confirm the recovery path issues a fresh request for the route segment rather than silently doing nothing β€” the trap described in Adding a Retry Button That Recovers Without a Full Reload.

Frequently asked questions #

Why is my production server component error just a digest string?

React strips server error messages before they reach the browser so internals cannot leak. Log the full error in onError alongside the digest and match them during triage.

Can a client error boundary catch a server component failure?

Yes, if it sits inside the Suspense region the server component streams into. It can re-request the segment but cannot re-run the server component in place.

What is onRecoverableError for?

It fires when React repaired something invisible to the user β€” usually a hydration mismatch. Reporting these surfaces mismatches that are silently costing performance.