Suspense placement
Suspense shows a fallback while a child loads; placing boundaries close to where data is needed lets already-loaded content reveal progressively, whereas one root boundary blanks the whole page when any small part is pending.
You know what <Suspense> does: it shows a fallback while a child is still loading, then swaps in the real content. So you wrap your page in one Suspense, drop a spinner in the fallback, and ship it. It works. The page loads.
Then someone notices: the user profile, the sidebar nav, and the static page chrome are all ready in 80ms — but the whole page sits on a spinner for 1.2 seconds because one slow analytics widget hasn’t resolved. Nothing is broken. The placement is wrong. Where you draw the boundary is not a detail — it’s a UX decision that determines whether your page reveals progressively or blanks as a single block.
After this lesson you can explain that Suspense reveals content at the granularity of its boundaries; place boundaries close to where each piece of data is needed so fast content paints immediately while slow content streams in behind its own fallback; recognize the failure mode of a single top-level Suspense (all-or-nothing blanking); and decide how granular to go by asking which parts of the page have independent load timings the user should see resolve independently.
Suspense reveals content at the granularity of its boundary — the boundary is the unit of “appears together”. Everything inside one <Suspense> is treated as one atomic chunk: React shows the fallback until every suspending child under it has resolved, then reveals all of them at once. So the boundary isn’t just “where the spinner goes” — it defines which slice of the UI lives and dies together.
// Everything under this ONE boundary appears together.
// If <SlowFeed> takes 1.2s, <FastHeader> and <FastSidebar>
// are hidden behind the fallback for the full 1.2s too.
<Suspense fallback={<PageSkeleton />}>
<FastHeader />
<FastSidebar />
<SlowFeed />
</Suspense>The lesson of this whole unit falls out of that sentence: if you want two parts of the page to reveal independently, they need to be under two boundaries.
Place boundaries close to where the data is needed, not only at the route root. The instinct from older patterns is one loading state per page. Suspense rewards the opposite: push the boundary down so the static shell and already-resolved data paint immediately, and each slow region carries its own fallback. The page becomes a set of independently-streaming holes in an otherwise-instant layout.
// The shell + header + nav are instant. Each slow region
// streams into its own slot behind its own fallback.
export default function Dashboard() {
return (
<DashboardShell>
<Header /> {/* paints immediately */}
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<RevenueStats /> {/* streams in independently */}
</Suspense>
</DashboardShell>
);
}Now a slow <RevenueStats> never holds the feed hostage, and neither holds the shell hostage. The user sees structure in 80ms and watches content fill in — far better perceived performance than one long blank.
One root boundary is all-or-nothing; granular boundaries reveal content progressively as it arrives. This is the core tradeoff. A single top-level Suspense gives you the simplest code and the worst experience for unbalanced load times: the page reveals at the speed of its slowest child. Granular boundaries cost a few more components but let each region reveal at the speed of its own data.
// ❌ all-or-nothing: page appears at the speed of the SLOWEST child
<Suspense fallback={<FullPageSpinner />}>
<Header /><Feed /><Stats /><Recommendations />
</Suspense>
// ✅ progressive: each region appears at the speed of ITS OWN data
<Header />
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense>
<Suspense fallback={<RecsSkeleton />}><Recommendations /></Suspense>In the streaming SSR / RSC model this is literal network streaming: the server flushes the shell and each boundary’s content as it resolves, so granular boundaries don’t just change when React reveals — they change what bytes arrive first.
The failure mode: a single top-level Suspense that blanks the entire page whenever any small part is loading. This is the anti-pattern this lesson exists to kill. A lonely analytics widget, a “people you may follow” list, a non-critical chart — any one of them suspending will hide your entire, otherwise-ready page behind the root fallback. The user pays the slowest component’s latency to see the fastest content.
// One slow, low-priority widget holds the whole page hostage.
<Suspense fallback={<FullPageSpinner />}>
<CriticalArticle /> {/* ready in 90ms... */}
<RelatedPosts /> {/* ...but blocked 1.4s by this */}
</Suspense>But the inverse is also a failure: a boundary around every leaf produces a flickering mess of skeletons and shifting layout that reads as jank, not progress. So the discipline is matching boundaries to meaningful independent load regions — wrap things that have genuinely different timings the user benefits from seeing resolve separately, and let things that load together share one boundary. Boundary placement is a judgment call about which chunks should appear together.
A profile page: from one root boundary to progressive reveal. The page has three regions with very different load timings — the user header (fast), the post feed (medium), and a “suggested follows” widget that hits a slow recommendation service.
Before — one root boundary. The fast header is held hostage by the slow widget:
export default function ProfilePage({ id }: { id: string }) {
return (
<Suspense fallback={<FullPageSpinner />}>
<ProfileHeader id={id} /> {/* resolves in ~100ms */}
<PostFeed id={id} /> {/* resolves in ~400ms */}
<SuggestedFollows id={id} /> {/* resolves in ~1.5s */}
</Suspense>
);
}
// User stares at a full-page spinner for 1.5s, then everything appears at once.After — boundaries placed close to each data need. The shell and header paint instantly; each slower region streams into its own slot:
export default function ProfilePage({ id }: { id: string }) {
return (
<ProfileLayout>
{/* Header resolves fast — give it its own boundary so it
isn't blocked by anything below it. */}
<Suspense fallback={<HeaderSkeleton />}>
<ProfileHeader id={id} />
</Suspense>
{/* The feed is the primary content — its own boundary so it
reveals as soon as it's ready, not when the widget is. */}
<Suspense fallback={<FeedSkeleton />}>
<PostFeed id={id} />
</Suspense>
{/* The slow, low-priority widget is isolated. It can take 1.5s
without affecting anything above it. */}
<Suspense fallback={<FollowsSkeleton />}>
<SuggestedFollows id={id} />
</Suspense>
</ProfileLayout>
);
}Now the header appears in ~100ms, the feed in ~400ms, and the suggestions fill in last — the user reads the profile and posts while the slow widget is still loading. Same components, same data, same total time; a dramatically better experience, decided entirely by where the boundaries sit. Note what we did not do: we didn’t wrap every avatar and every post row in its own Suspense — that would flicker. Three boundaries match the three genuinely-independent load regions.
▸Why this works
Why does “close to the data” beat “one at the root”? Because perceived performance is dominated by time-to-first-meaningful-content, not time-to-fully-loaded. A user who sees the page structure and their header in 100ms perceives the app as fast even if a sidebar widget is still spinning at 1.5s. A root boundary throws away that win by coupling first paint to last paint. Granular boundaries decouple them — which is the entire point of streaming UI: ship what’s ready, stream the rest.
▸Edge cases
Granular isn’t always more granular-is-better. Two cases pull the other way. First, layout stability: if revealing regions independently causes content below to jump as each skeleton is replaced, reserve space in the skeleton (fixed heights) or group regions under one boundary so they reveal together — a shared boundary is the right tool when independent reveal would just cause shift. Second, deliberate grouping: sometimes two pieces are meaningless apart (a chart and its legend, a price and its currency badge); wrap those in one boundary on purpose so they never appear half-rendered. The rule isn’t “maximize boundaries” — it’s “one boundary per group that should appear together.”
A dashboard wraps its header, primary chart, and a slow third-party 'recommended actions' widget in a single root <Suspense fallback={<FullPageSpinner />}>. The header and chart resolve in ~150ms; the widget takes ~1.4s. What does the user experience, and what's the senior fix?
<Suspense> reveals content at the granularity of its boundary: everything under one boundary is one atomic chunk that appears together when its slowest suspending child resolves. So where you place the boundary is a UX decision. A single root Suspense is all-or-nothing — the page reveals at the speed of its slowest child, and one slow low-priority widget can blank an otherwise-ready page (the failure mode this lesson kills). Placing boundaries close to where each piece of data is needed lets the static shell and fast regions paint immediately while slow regions stream into their own fallbacks — far better perceived performance, and in streaming SSR/RSC, literally better byte ordering. But don’t over-shard: a boundary per leaf flickers and shifts layout. The discipline is one boundary per group of content that genuinely should appear together — match boundaries to independent load regions, not to every component.
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.