Skip to content

Put Your Suspense Boundaries Around Data, Not the Page

Suspense for data fetching only works well with correct boundary placement. This is the granular pattern that avoids the giant loading spinner mistake.

· · 7 min read
A low-angle view of a metal lattice structure

Quick Take

My first Suspense implementation wrapped the entire page in one boundary and called it done. Every section flashed a loading spinner at once, then popped in together, which looked worse than no Suspense at all.

Suspense for data fetching is a React pattern where components read pending promises directly, and the closest <Suspense> boundary shows a fallback until every promise inside it resolves. 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.

Quick take: 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.

What Goes Wrong With One Boundary Around Everything?

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. According to the React docs, a <Suspense> boundary treats every descendant that suspends as a single unit, it does not distinguish between one slow child and three fast ones, so the boundary only reveals its content once all of them have resolved, not just the first or the majority. That all-or-nothing behavior is what makes coarse boundary placement expensive: a single 1200ms fetch nested three components deep can silently add a full second of perceived latency to two components that were ready in under 400ms combined.

How Does One Boundary Per Section Fix It?

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. A Suspense boundary is the <Suspense fallback={...}> element itself, the unit React uses to decide which fallback to show and which descendants it is responsible for; three separate boundaries mean three separate resolution timelines, which is the entire mechanism this pattern relies on.

When Should You Nest Suspense Boundaries?

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. Per the React docs, nested boundaries don't change how any individual boundary resolves, each one still waits for all of its own descendants, they only change the granularity at which fallbacks appear, so a dashboard with three or four independently-loading widgets ends up with three or four boundaries instead of one, each swapping its own fallback the moment its own data is ready rather than waiting on unrelated siblings.

How Do You Size 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. Cumulative Layout Shift is one of Google's three Core Web Vitals, measured on a 0 to roughly 1-plus scale where anything above 0.1 is flagged as needing improvement, and an unsized Suspense fallback is a common, easily overlooked source of it since the shift only shows up after data finishes loading, well outside the window most manual testing covers.

How Does Suspense With use() Compare to 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. According to the TanStack Query documentation, useSuspenseQuery and the plain use() hook both trigger the exact same boundary mechanism under the hood, the difference is entirely in what happens before that point: caching, deduplication across two components requesting the same key, and background refetching are library features layered on top of the promise, not something the Suspense boundary itself provides or cares about:

// 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.

Manual use()TanStack Query useSuspenseQuerySWR suspense: true
Suspends the boundaryYesYesYes
CachingNo, reads whatever promise it's givenYesYes
Deduplication across componentsNoYesYes
Refetch behaviorNoYesYes

How Do You Test 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.

Steps to test a suspending component correctly:

  1. Wrap the component under test in its own <Suspense fallback={...}>, matching production structure.
  2. Render with a promise or mocked query hook that resolves after a tick, not synchronously.
  3. Use findBy* queries to await the fallback being replaced by real content.
  4. Assert against the resolved content only after that await completes, not immediately after render.

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.

Frequently Asked Questions

What does a Suspense boundary actually do?
A <Suspense fallback={...}> boundary catches any descendant component that suspends, meaning it reads a pending promise via use() or a Suspense-integrated data library, and shows the fallback UI in its place until the promise resolves. Once resolved, React swaps the fallback for the real content. A single boundary can wrap multiple components, in which case all of them stay hidden behind the fallback until every one of them has resolved, not just the slowest.
Should every component that fetches data have its own Suspense boundary?
Not every single one, but every independent section of a page that can reasonably load at its own pace should. A page header that depends on user data and a comments section that depends on a separate fetch are good candidates for separate boundaries, so a slow comments API doesn't block the header from appearing. Two pieces of data that are only ever meaningful together, like a chart and its own legend, are fine sharing one boundary.
How do I avoid a layout shift when a Suspense fallback resolves?
Give the fallback the same dimensions as the real content, a skeleton loader sized to match, rather than a small spinner in an otherwise empty space. If the fallback is much smaller than the resolved content, the page height changes the instant the real content appears, which is a layout shift by definition, the same CLS problem an unsized image causes, just triggered by a Suspense boundary resolving instead of an image loading.