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.
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.
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 #
- Throw in one server component. The other regions must still render, the failing region shows its fallback, and the shell is intact.
- 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.
- Break the shell deliberately. Throw at the top of the route before any
Suspense; confirmonShellErrorfires and a static error page is served with a 500 status. - Force a hydration mismatch. Render a timestamp on the server; confirm
onRecoverableErrorreports it even though the page looks fine. - 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.
Related #
- React Error Boundary Implementation β the boundary component these regions use
- Using global-error.tsx to Catch Root Layout Crashes in Next.js β the framework-level equivalent of a shell failure
- Hydration Mismatch & State Recovery β diagnosing what
onRecoverableErrorreports