Streaming SSR with Suspense in React: what happens to error boundaries once rendering stops being all-or-nothing
Traditional SSR had one property that made error handling simple: either the full HTML got generated, or nothing got sent at all. renderToString runs synchronously, top to bottom, and if anything in the tree throws, the error reaches our code intact before a single byte has gone out over the wire. That property disappears with the streaming SSR that Suspense enables, and it's not an implementation detail — it changes what an error boundary can do, and when it can do it.
What we took for granted with synchronous SSR
With renderToString or renderToStaticMarkup, the usual pattern was to wrap the whole render in a try/catch and only then decide what to send back:
try {
const html = renderToString(<App />);
res.status(200).send(wrapInDocument(html));
} catch (err) {
logError(err);
res.status(500).send(renderErrorPage());
}
A single error boundary at the root of <App /> was enough to catch any render error, because there was nothing sent yet to protect. The status code decision — 200 or 500 — got made once the whole tree had already resolved in memory. There was no "we already sent half the response" problem: either there was a complete response, or there was an error we could intercept before the client saw anything at all.
What renderToPipeableStream changes
Streaming SSR flips that sequence around. Instead of waiting for the entire tree to resolve, React first sends a "shell" — the part of the UI that doesn't depend on async data — and fills in the rest with additional chunks as each Suspense boundary resolves its promise. Each chunk arrives as HTML followed by a small inline script that swaps the matching fallback in the DOM.
const { pipe, abort } = renderToPipeableStream(<App />, {
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(err) {
logError(err);
res.statusCode = 500;
res.send(renderErrorPage());
},
onError(err) {
didError = true;
logError(err);
},
onAllReady() {
// useful for crawlers or pre-rendering; not part of our normal path
},
});
The split between onShellError and onError is where the whole contract change lives. onShellError fires when the error happens before the shell is ready to be sent: at that point we still haven't sent anything, so we can act just like in the synchronous model, returning a 500 and a full error page. onError fires for any error that happens afterward, inside a Suspense boundary that's already part of a response whose headers and shell have already left the socket. By then the response's status code has already been decided and sent. onError can't change it. All it can do is log the error.
What happens to a chunk that fails after others have already been sent
Picture a page with three independent sections, each in its own Suspense boundary: a header with user data, a body with the main content, and a sidebar with recommendations. The shell goes out with all three fallbacks visible. If the recommendations section fails while resolving its data, there are two possible outcomes, and which one happens depends on something that used to be irrelevant: whether that Suspense boundary has its own error boundary wrapping it.
With a dedicated error boundary around that section, React catches the error, drops that part of the tree, and streams a replacement chunk: the section's original fallback gets swapped for the boundary's error UI, without touching the header or body that already rendered fine.
<Sidebar>
<ErrorBoundary fallback={<RecommendationsUnavailable />}>
<Suspense fallback={<SidebarSkeleton />}>
<Recommendations />
</Suspense>
</ErrorBoundary>
</Sidebar>
Without that local error boundary, the error bubbles up looking for the next boundary that does have one — in practice, it often ends up at the root, if there's one there at all. The problem is that, by then, the root already went out as part of the shell. React can't "undo" HTML that already streamed to show a different error page instead. What actually happens is that the affected section keeps showing its loading fallback indefinitely in the HTML that arrived via streaming, and on the client side, once React hydrates, it tries rendering that part again — sometimes it recovers if the error was transient, sometimes it hits the same error again, now on the client. Neither outcome is a clean 500: the response already went out with status 200, and all that's left is whatever we captured in onError for internal logging.
What we had to redesign
We had a page — a dashboard with several independent widgets, each with its own data source — built on the assumption of synchronous SSR: a single error boundary at the root, a single try/catch, and a simple rule in the monitoring pipeline that alerted on any 500 response. When we migrated to streaming, that alerting rule stopped catching individual widget failures, because a widget that broke after the shell no longer produced a 500: it produced a 200 with a broken section inside it, invisible to any check that only looked at the response's status code.
The first thing we changed was the granularity of the error boundaries: we went from one at the root to one per widget with its own Suspense boundary, following the same idea that separates header, body, and sidebar in the example above. That keeps a failure in one widget from taking down the rest of the page, which was exactly the behavior we wanted to preserve.
The second was dropping the HTTP status code as our failure signal for anything that wasn't a pre-shell error. We added an explicit call to our monitoring system inside every error boundary — both in React's lifecycle method (componentDidCatch) and in renderToPipeableStream's own onError callback — so a partial failure still got logged the way it used to, even though the response's status stayed at 200.
class WidgetErrorBoundary extends React.Component {
componentDidCatch(error, info) {
reportToMonitoring({ error, widget: this.props.widgetName, info });
}
render() {
if (this.state?.hasError) return this.props.fallback;
return this.props.children;
}
}
The third was rethinking what we put inside each Suspense fallback. Previously the fallback was just a transient loading state that disappeared within milliseconds. Now, given the real possibility that a fallback stays visible indefinitely if the client never recovers, we designed every fallback to be a UI we'd be fine leaving on screen: no "loading" copy that implies an expiration date, and a manual retry action wherever it made sense, instead of relying solely on client-side hydration to sort it out on its own.
Last, we kept the shell deliberately free of its own async data dependencies, not just the widgets'. Anything that can fail while the shell is being generated — before onShellReady — still lands in onShellError, where we can still decide the status code and show a real error page. The thinner the shell and the less it depends on data, the fewer cases end up resolving in the safety-net-free path that is onError.
What we check before moving a page to streaming SSR
Before turning on renderToPipeableStream for a page that used to render synchronously, we confirm that every section with its own Suspense boundary also has its own error boundary, because a single boundary at the root no longer protects anything once the shell has already gone out via streaming. We check that no monitoring rule relies solely on the HTTP status code to catch partial failures, and we add explicit logging inside each boundary to cover exactly the cases a 200 can no longer give away. We design every fallback assuming it might stay visible indefinitely, not just for a few milliseconds. And we keep the shell free of its own data dependencies, so that most real errors still land in onShellError — the one place where we still have full control over the response.