React.js, part 16: Suspense boundaries in production — streaming, nested fallbacks, and error recovery
Part 16from the React.js series · 20 parts in all
Placing one <Suspense> at the top of the app is the demo version. In
production the interesting work is deciding where boundaries go, so that the page
streams in useful pieces instead of one big spinnner, and so that a single failure does not
take down a page that was mostly fine.
Streaming is a consequence of boundary placement
The server begins sending HTML as soon as the shell is ready; each boundary's fallback goes out immediately, and its real content streams in when it resolves. That means the boundary structure is the loading experience:
<Page>
<Header /> {/* no data: renders at once */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations userId={id} /> {/* independent: streams in */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<Orders userId={id} /> {/* slow: does not block the rest */}
</Suspense>
</Page>
Because Suspense boundaries are independent, one slow query delays only its own section. Put the boundary inside the layout around the slow child, never around the whole page, unless you genuinely want the whole page to wait.
Errors belong next to the boundary that hides the content
A thrown error during streaming must be caught, or it cancels the stream for everything after it. Error boundaries and Suspense are a matched pair — wrap the same subtree in both:
<ErrorBoundary fallback={<OrdersUnavailable />}>
<Suspense fallback={<OrdersSkeleton />}>
<Orders userId={id} />
</Suspense>
</ErrorBoundary>
The design principle: a boundary is a promise about failure scope. If "recommendations are down" should not blank the order history, they must not share a boundary.
The hydration rules that still surprise people
- Do not read browser-only values during render. A boundary that renders
differently on server and client causes a hydration mismatch; measure in an effect or use
useSyncExternalStorewith a server snapshot. - Fallbacks must not be interactive. Anything clickable in a fallback is replaced the moment content arrives — render it inert.
- A transition hides fallbacks on the client. Suspending inside
startTransitionkeeps the previous UI instead of showing the fallback again, which is usually what you want for in-place navigation.
Next: startTransition with async functions, and
useEffectEvent as the escape hatch for "latest value" in an effect.