Skip to content

Suspense for Data Fetching: The 2026 Pattern Guide

Where to Actually Put Your Suspense Boundaries

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

· · 4 min read

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.

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.

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.