Suspense boundaries: designing what disappears together
A Suspense boundary decides what disappears together: the nearest boundary above a suspender shows its fallback. Nesting controls reveal order, fallbacks must reserve space or cause CLS, and startTransition keeps shown content on screen instead of swapping it for a spinner.
The product page of a mid-size e-commerce site converted fine until the personalization team shipped a “customers also bought” rail. The rail fetched from an ML ranking service with a p99 of 2.8 seconds. Nobody touched the page layout — they just dropped the new component into the tree. The next morning, conversion was down 9%. Session replays showed why: for almost three seconds, users saw a full-page spinner where the product title, price, photos, and the buy button used to be. The page had exactly one Suspense boundary — at the route root, added eighteen months earlier “to have a loading state.” The new rail suspended, React walked up the tree looking for the nearest boundary, found the root, and dutifully replaced the entire page with the fallback. The data was fine, the components were fine, the fetch was fine. The only bug was geometry: a boundary placed where nobody had thought about what it would make disappear. A Suspense boundary is not error handling for loading — it is a design decision about which pixels are allowed to vanish together, and the tree position you give it is the entire content of that decision.
The boundary is a design unit, not plumbing
When a component suspends — reads a promise that is not yet resolved, via use, a Suspense-enabled framework fetch, or React.lazy — React does not pause that one component. It walks up the tree to the nearest <Suspense> ancestor, abandons rendering everything below that boundary, and shows the boundary’s fallback instead. Two consequences follow, and both are about placement, not data:
- Everything under the chosen boundary disappears together. Siblings of the suspender that already had their data — the product title, the buy button — vanish too, because they live under the same boundary. The boundary, not the component, is the granularity of loading UI.
- The nearest boundary wins. Wrap the slow rail in its own boundary and only the rail’s slot shows a skeleton; leave it bare and the suspension propagates to whatever ancestor boundary exists, however far up — including the route root, which is how a widget fetch becomes a full-page spinner.
// ❌ one boundary at the root: ANY suspender blanks the whole page
<Suspense fallback={<FullPageSpinner />}>
<ProductHeader /> {/* fast */}
<BuyBox /> {/* fast */}
<RecommendationRail /> {/* p99 2.8s — takes everyone down */}
</Suspense>
// ✅ boundaries follow the design's loading sequence
<ProductHeader />
<BuyBox />
<Suspense fallback={<RailSkeleton />}>
<RecommendationRail /> {/* slow — disappears alone */}
</Suspense>The right way to choose placement is to ask the question designers already answer for skeleton screens: which regions of this page load as a unit? Each answer is a boundary. A boundary per component is as wrong as one per page — fifteen independent spinners popping in random order read as broken, one giant spinner reads as slow. The react.dev guidance is blunt about this: do not put a boundary around every component; loading sequences should match what the design intends users to see.
A team adds a slow analytics widget to a dashboard. The route has a single Suspense boundary at its root. After the deploy, opening the dashboard shows a full-page spinner for ~3 seconds even though every chart's data returns in 200ms. What happened?
Nesting, reveal order, and the throttle
Nested boundaries give you a reveal sequence. The outer boundary resolves first and reveals the layout shell; inner boundaries keep showing their skeletons until their own data lands. The order is strictly top-down — an inner boundary can never reveal before its parent, because until the parent reveals, the inner content is not on screen at all. This is how you encode “navigation first, then the article, then the comments” directly in the tree.
There is a detail seniors should know because it shows up in profiler traces: React throttles the reveal of nested boundaries. When several nested boundaries resolve in quick succession, React batches the reveals rather than popping each one the instant it is ready — the throttle interval is on the order of 300ms (a constant in React’s source, not configurable). The motivation is flicker: without it, a fast connection produces a cascade of skeletons each visible for 16ms, which reads as glitching. The cost is honest too: on a fast cache hit, content can appear marginally later than the data arrived. If you see a ~300ms gap between data resolution and paint inside nested Suspense, that is not your bug — it is React trading raw latency for visual calm.
The fallback is layout: reserve the space
A fallback is not decoration — it occupies the boundary’s slot in the layout. A 40px spinner standing in for a 480px card means the page reflows twice: once when the spinner renders, once when content replaces it. That is Cumulative Layout Shift, and the field threshold is unforgiving — CLS above 0.1 fails Core Web Vitals, and a single full-height swap can blow the whole budget on its own. The discipline: every fallback is a skeleton with the same dimensions as the settled content — same height, same aspect-ratio boxes for images, min-height on containers whose content height varies. Treat the skeleton as a contract with the design system, not an afterthought; teams that generate skeletons from the real component’s layout (grey boxes in the same grid) ship near-zero CLS, teams that center a spinner ship 0.2+.
Refining shown content: the fallback you must avoid
The rules change once a boundary has revealed. If an update — a new search query, a filter change — causes already-visible content to suspend again, React’s default is brutal: it hides the committed content and shows the fallback again. Your results list blinks into a skeleton on every keystroke. This is exactly what startTransition (and useTransition) exists to prevent: when the update that caused the suspension is marked as a transition, React does not hide visible content. It keeps the old UI on screen, renders the new tree in the background, and swaps when ready — isPending gives you the dimmed-list affordance in the meantime.
const [isPending, startTransition] = useTransition();
function onQueryChange(q) {
setInput(q); // urgent: the input must echo now
startTransition(() => setQuery(q)); // non-urgent: results may suspend — keep old list
}One honest caveat: a transition protects only already-revealed content. If the new tree mounts a brand-new boundary that suspends, that boundary shows its fallback regardless — there is nothing old to keep on screen in that slot. So the placement question returns even here: boundaries you expect to refine in place should wrap stable slots, not be re-created per query.
A search page shows results under a Suspense boundary. Every keystroke updates the query and the entire results panel flips to the skeleton for 300ms, then back. The data layer is fine. What is the senior fix?
Failure mode catalog
- The root-only boundary — every new suspender anywhere in the route inherits a full-page spinner. The blast radius of a widget becomes the page. This is the Hook’s incident, and it recurs because the root boundary “works” for months until someone adds a slow leaf.
- Boundary-per-component — popcorn UI: a dozen skeletons resolving in arbitrary order, layout twitching with each. Reveal order should be designed, not emergent.
- Spinner fallbacks with wrong dimensions — CLS failures that show up in field data (CrUX) but not on your fast dev machine, because locally the fallback is visible for one frame.
- Refine without transition — visible content punched back to skeleton on every interaction; users read it as the app losing their data.
- 01Walk through exactly what React does when a component suspends, and explain how one slow widget can blank an entire page.
- 02What happens when already-visible content suspends again on update, and what are the two caveats around transitions and nested reveals worth knowing in a profiler?
A Suspense boundary is a design decision wearing a component’s clothes. The mechanism is small: a suspending descendant routes to the nearest boundary above it, and that entire boundary — suspender and innocent siblings alike — is replaced by the fallback. Everything else follows from placement. One root boundary makes every future slow component a full-page spinner, which is how a recommendations rail with a 2.8-second p99 took down a product page’s conversion; a boundary per component produces popcorn skeletons in arbitrary order. The right granularity is the design’s own loading regions, encoded as nested boundaries that reveal strictly top-down, with React batching nested reveals on a roughly 300ms throttle to avoid flicker. Fallbacks are layout: a skeleton must occupy the same dimensions as the settled content or the swap registers as CLS against the 0.1 budget that field data is graded on. And once content is on screen, the default changes from helpful to hostile — a re-suspending update hides committed content behind the fallback again, unless the update is marked as a transition, in which case React keeps the old UI, renders in the background, and hands you isPending for the dimmed state. The transition protects only already-revealed content; brand-new boundaries still show fallbacks. Place boundaries where the design wants regions to vanish together, give every fallback the real content’s footprint, and refine visible content only inside transitions. Now when you see a full-page spinner appearing where only one section should have gone missing, you know exactly where to look: find the nearest Suspense boundary above the slow component, and ask whether it is scoped to the right region — or whether it was placed once, years ago, and never reconsidered.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.