The single-boundary mistake is an easy one to make because it's the first thing that works. Wrap the whole page, show a spinner, watch it swap to real content. It's also the version that makes every page feel slower than necessary, because the fastest piece of data on the page is held hostage by the slowest one.
TL;DR: Place a
<Suspense>boundary around each independently-loading section of a page, not one boundary around the whole thing. A fast header shouldn't wait behind a slow comments section. Size fallback skeletons to match their resolved content's dimensions to avoid a layout shift when the real content swaps in, and nest boundaries so a slow inner section doesn't block an outer one that's ready sooner.
The Mistake: One Boundary, Everything Waits
function ProductPage({ productId }) {
return (
<Suspense fallback={<FullPageSpinner />}>
<ProductHeader productId={productId} /> {/* resolves in 100ms */}
<ProductReviews productId={productId} /> {/* resolves in 1200ms */}
<RelatedProducts productId={productId} /> {/* resolves in 400ms */}
</Suspense>
);
}
All three components suspend independently, but because they share one boundary, the entire page stays behind <FullPageSpinner /> until the slowest of the three, reviews at 1200ms, resolves. The header's data was ready after 100ms and the user saw nothing for over a second longer than necessary.
The Fix: One Boundary Per Independent Section
function ProductPage({ productId }) {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<ProductHeader productId={productId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={productId} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
</>
);
}
Now the header appears at 100ms, related products at 400ms, and reviews at 1200ms, each independently, instead of everything waiting for the slowest. The user sees progressive, meaningful content instead of one long blank wait followed by everything popping in simultaneously.
Nesting Boundaries for Sections With Their Own Sub-Sections
function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardHeader />
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrdersTable />
</Suspense>
</Suspense>
);
}
The outer boundary covers <DashboardHeader /> (assume it doesn't suspend, or resolves fast) plus acts as a catch-all. The inner boundaries let RevenueChart and RecentOrdersTable resolve independently of each other, and independently of anything in the outer boundary that isn't wrapped separately. This nesting is the pattern that scales, coarse-grained boundaries at the page level, finer-grained ones around genuinely independent widgets within it.
Sizing Fallbacks to Avoid Layout Shift
// Wrong: fallback is much smaller than the resolved content
<Suspense fallback={<Spinner />}>
<ProductReviews productId={productId} /> {/* renders as a 600px-tall list */}
</Suspense>
// Right: fallback reserves the same approximate space
<Suspense fallback={<div style={{ minHeight: '600px' }}><ReviewsSkeleton /></div>}>
<ProductReviews productId={productId} />
</Suspense>
A tiny centered spinner swapping for a 600px-tall review list is a textbook CLS event, the page's height jumps the instant the real content mounts. Sizing the fallback to roughly match, even an approximate skeleton with placeholder bars at the right proportions, keeps the layout stable through the transition.
Suspense With use() vs a Data Library
Suspense doesn't fetch anything itself, it reacts to a descendant suspending, which happens when that descendant calls use() on a pending promise, or when a Suspense-integrated library (TanStack Query with useSuspenseQuery, SWR's suspense: true option) throws a pending promise internally:
// Manual use(), promise created upstream (e.g. in a Server Component)
function Reviews({ reviewsPromise }) {
const reviews = use(reviewsPromise);
return <ReviewList reviews={reviews} />;
}
// TanStack Query's Suspense-integrated hook
function Reviews({ productId }) {
const { data: reviews } = useSuspenseQuery({
queryKey: ['reviews', productId],
queryFn: () => fetchReviews(productId),
});
return <ReviewList reviews={reviews} />;
}
Both suspend the same way from the boundary's perspective. The library-based version additionally gives you caching, deduplication across components requesting the same data, and refetch behavior, which use() alone doesn't provide, it only reads whatever promise it's given.
Testing Components That Suspend
A component wrapped in use() or a Suspense-integrated query hook needs a promise to resolve (or reject) during a test, just like it does in the browser. Render it inside a <Suspense> boundary in your test, await the fallback disappearing (React Testing Library's findBy* queries handle this by waiting), then assert against the resolved content. Skipping the boundary in a test and rendering the suspending component directly throws immediately, since there's nothing there to catch it, the same failure mode you'd get in production if a boundary were missing from the real component tree.
Conclusion
Suspense boundary placement is the actual design decision, the API itself is simple. Match each boundary to one independently-loading section of the page, size fallbacks to avoid layout shift when they resolve, and nest boundaries so a slow inner section never blocks a page shell or unrelated sibling that's ready sooner. The single-boundary version works, it's just the version that makes fast data wait for slow data with nothing gained in return.