All articles
Web DevelopmentSeptember 5, 2026 6 min read

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor

We wrapped everything in Suspense expecting faster pages. Instead LCP got worse. Here's what streaming actually does to Core Web Vitals, and how to place boundaries so the numbers move the right way.

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor

A team we work with shipped a big App Router refactor last quarter. They wrapped their product detail page in Suspense boundaries around every async section — reviews, recommendations, stock status, shipping estimator. The bet was obvious: stream the shell fast, hydrate the rest progressively, watch LCP drop.

LCP got worse. Not by a rounding error — by roughly 400ms at the 75th percentile. TTFB improved, INP was fine, CLS crept up. The team's first instinct was to blame the CDN. It wasn't the CDN.

What streaming actually changes

Before we talk about the fix, it helps to be precise about what a Suspense boundary does in the App Router. When React hits a boundary during server rendering, it emits the fallback into the initial HTML and continues rendering the rest of the tree out-of-order. The suspended chunks arrive later as <template> tags with inline scripts that swap them into place.

This is great for TTFB because the browser gets something immediately. It's neutral or bad for LCP if your largest contentful element happens to be inside — or visually dependent on — a boundary that suspends.

The mental model that trips people up: "more boundaries = more parallelism = faster page." That's only true when the boundaries are placed around genuinely slow, non-critical work. If your hero image, headline, or price sits behind a boundary that waits on a slow API, you've just moved your LCP element to the back of the queue.

The boundary hierarchy that matters

Think of a page as three layers:

  1. Critical above-the-fold content — the LCP candidate, primary CTA, headline. This should render in the first flush of HTML, from cached or fast data. No Suspense boundary between it and the root.
  2. Important but deferrable — secondary content the user will see within a second or two. Suspense here is fine and often ideal.
  3. Below-the-fold or interaction-gated — reviews, related products, comment threads. Suspense with a skeleton, or lazy-load entirely.

The refactor we walked into had put layer 1 behind the same boundary as layer 3 because the fetch was colocated in a shared server component.

Diagnosing it with the Performance panel

Before touching code, we recorded a trace in Chrome DevTools with network throttling set to Fast 4G. The tell is in the streaming waterfall: you'll see the initial HTML document arrive quickly, then a series of small chunks over the next several hundred milliseconds. Each chunk corresponds to a resolved Suspense boundary.

Find the LCP marker in the Timings track, then look at what chunk contained that element. If the LCP element arrived in a late streaming chunk instead of the initial document, you've found your problem.

A quick way to confirm from code:

// app/product/[id]/page.tsx
import { Suspense } from 'react'
import { ProductHero } from './product-hero'
import { Reviews } from './reviews'
import { Recommendations } from './recommendations'

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <main>
      {/* LCP element lives inside ProductHero */}
      <Suspense fallback={<HeroSkeleton />}>
        <ProductHero id={params.id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={params.id} />
      </Suspense>

      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations id={params.id} />
      </Suspense>
    </main>
  )
}

That first boundary is the problem. The hero contains the product image — the LCP candidate — and it's suspending on the same product fetch that would have happened during a non-streamed render anyway. All we did was add a skeleton flash and delay the real element by a paint cycle.

The fix: hoist critical data, boundary the rest

The correction is to fetch the critical data at the page level (or in a parent server component with no Suspense above it) and only wrap the genuinely slow, non-critical work.

// app/product/[id]/page.tsx
import { Suspense } from 'react'
import { getProduct } from '@/lib/products'
import { ProductHero } from './product-hero'
import { Reviews } from './reviews'
import { Recommendations } from './recommendations'

export default async function ProductPage({
  params,
}: {
  params: { id: string }
}) {
  // Fast, cached, indexed lookup — blocks the shell intentionally
  const product = await getProduct(params.id)

  return (
    <main>
      <ProductHero product={product} />

      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={params.id} />
      </Suspense>

      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations id={params.id} />
      </Suspense>
    </main>
  )
}

Now the LCP element ships in the initial HTML flush. TTFB gets slightly worse — you're waiting on getProduct before responding — but LCP improves because the largest paint happens on first render, not after a stream chunk arrives.

In our experience the tradeoff is worth it as long as the critical fetch is cached or hits a fast primary key lookup. If it isn't, fix the fetch, don't hide it behind Suspense.

When Suspense actually helps LCP

There is one case where wrapping the LCP-containing subtree in Suspense helps: when the surrounding chrome (nav, header, layout) renders from static or fast sources, and the LCP element itself depends on slow data that you can't speed up. In that case the fallback should be a visually similar placeholder at the same dimensions so the browser can still calculate layout, and the LCP element itself should be the fallback — not the resolved content — so the browser measures the paint at first render.

That's a subtle trick and it only works if your skeleton is the same size and roughly the same visual weight as the final element. Otherwise you'll trade LCP for CLS.

CLS: the second-order problem

The other regression in that refactor was CLS. Every boundary that resolved caused a layout shift because the skeletons were smaller than the content that replaced them. The fix is boring but strict:

  • Every skeleton must reserve the exact final dimensions.
  • Use aspect-ratio on image containers.
  • Set min-height on text blocks where you know the character count is bounded.
  • Never let a boundary resolve into content that pushes other content down.

We wrote a lint rule that flags any fallback prop pointing to a component whose root element has no explicit height, min-height, or aspect-ratio class. It caught most of the offenders in a single afternoon.

A checklist before you ship

Before any App Router page goes to production, we run this list:

  • Identify the LCP candidate (usually the hero image or headline). Confirm it's not inside a Suspense boundary unless the fallback is dimensionally identical.
  • Trace the data dependencies of the LCP element. Are they cached? Indexed? Under 100ms at p95?
  • For every Suspense boundary, check that the fallback reserves exact final dimensions.
  • Run a Lighthouse trace on throttled 4G, not on your MacBook over office fibre.
  • Check the streaming waterfall in DevTools — you want the LCP chunk in the first flush.
  • Verify generateMetadata isn't blocking on a slow fetch. It runs before the response starts and will destroy TTFB.

That last one deserves its own paragraph because it burns people constantly. generateMetadata is awaited before Next.js sends the first byte. If it hits a slow upstream, your whole streaming strategy dies before the first <Suspense> boundary even matters. Cache it, or derive it from data you've already fetched via React's cache() deduplication.

Where we'd start

If you're inheriting an App Router codebase with soft Core Web Vitals, don't start by adding Suspense boundaries. Start by opening a production trace, finding the LCP element, and walking backwards from there. Nine times out of ten the fix is removing a boundary, hoisting a fetch, or fixing a metadata call — not adding more streaming.

Suspense is a scalpel, not a performance plugin. Use it where the content is genuinely deferrable and the fallback is honest about its final size. Everything else belongs in the initial HTML flush.

If you want a second pair of eyes on a slow App Router page, our web development team does this kind of triage regularly, and there are more field notes on the blog.

#Next.js#React#Performance#Core Web Vitals#Suspense

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project