Streaming Suspense Boundaries: Where to Draw Them Without Breaking LCP
Suspense boundaries in the Next.js App Router are a scalpel, not a sledgehammer. Put them in the wrong place and you'll ship a page that streams beautifully but tanks LCP. Here's how we decide where they go.

Streaming with Suspense is one of those App Router features that demos beautifully and quietly ruins your Core Web Vitals if you use it the way the tutorials show. We've watched teams wrap every async component in <Suspense>, congratulate themselves on the fast TTFB, then wonder why LCP got worse.
This is a field guide to placing Suspense boundaries with intent. Not "stream everything," not "stream nothing" — a set of rules we actually apply on client work.
Why Suspense boundaries move your metrics around
A Suspense boundary is a promise to the browser: "I'll send you a placeholder now, and stream the real content later in the same response." That's powerful. It's also a trade.
When you wrap a component in <Suspense fallback={...}>, three things happen:
- The shell (everything outside the boundary) can flush to the browser immediately.
- The fallback markup ships inside the shell.
- The real content arrives later, injected via an inline
<script>that swaps the fallback.
That second point is the trap. If your Largest Contentful Paint element ends up inside a Suspense boundary, the browser measures LCP against whatever the fallback renders — usually a skeleton — and then again when the real content lands. Google's field data reports the final paint. So a boundary around your hero image doesn't make LCP faster. It makes it later, because the real element only appears after the async work resolves and the streamed chunk arrives.
Meanwhile, TTFB looks fantastic. That's the mismatch that fools teams.
The mental model we use
Suspense is for content the user does not need to see first. Everything above the fold that counts as LCP should render synchronously in the initial server payload, or be static.
If you internalize that one line, most of the rest of this article is just tactics.
The four zones of a typical page
When we audit a Next.js route, we mentally split the page into four zones and treat each differently.
Zone 1 — Above-the-fold, LCP-critical. Hero title, primary image, first paragraph, primary CTA. No Suspense. Data for this zone should be fast (cached, static, or from a colocated fast source). If it's slow, fix the data path, don't hide the problem behind a skeleton.
Zone 2 — Above-the-fold, secondary. Nav counters, personalized greetings, cart badge. These can Suspense-stream if their fallback is visually stable — same dimensions, same layout — so they don't cause CLS.
Zone 3 — Below-the-fold, expensive. Related products, reviews, activity feeds. Aggressive Suspense boundaries here are almost always a win.
Zone 4 — Interactive islands. Client components hydrated after the fact. Suspense boundaries here matter for hydration order, not paint.
Here's what that looks like in practice for a product page:
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { getProduct } from '@/lib/products';
import { ProductHero } from './product-hero';
import { ReviewsSkeleton, Reviews } from './reviews';
import { RelatedSkeleton, Related } from './related';
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Fast, cached read. This blocks the response — on purpose.
const product = await getProduct(slug);
return (
<main>
{/* Zone 1: no Suspense. This IS the LCP. */}
<ProductHero product={product} />
{/* Zone 3: slow, below the fold. Stream it. */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<Related categoryId={product.categoryId} />
</Suspense>
</main>
);
}
Notice what's not here: no boundary around ProductHero. If getProduct is slow, that's a caching problem, not a rendering problem.
The three failure modes we see most
1. Wrapping the LCP element
The most common one. Someone reads that Suspense enables streaming, wraps the hero, ships it, and LCP goes from 1.8s to 2.9s in field data. Because now the hero image can't start downloading until the streamed chunk arrives and hydration begins to resolve the image element.
If your hero image is server-rendered without a Suspense boundary around it, the browser's preload scanner can find it in the initial HTML flush and start the request immediately. Wrap it, and you lose that entire optimization.
2. Fallbacks that don't reserve space
A skeleton that renders at 200px tall while the real component is 480px causes a CLS event when it swaps. Suspense doesn't cause CLS by itself; poorly measured fallbacks do.
Rule: fallback dimensions must match real content within a few pixels. We use CSS aspect ratios and min-height for anything with variable-height content.
export function ReviewsSkeleton() {
return (
<section
aria-busy="true"
aria-label="Loading reviews"
className="min-h-[420px] space-y-4 py-8"
>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 rounded-md bg-neutral-100" />
))}
</section>
);
}
The aria-busy matters too — screen readers should know something's coming. That's the accessibility half people forget.
3. Nested boundaries with waterfalls inside
Suspense doesn't parallelize your data fetches. It parallelizes rendering around them. If two components inside the same boundary each await sequentially, you still have a waterfall.
The fix is to kick off the fetches in parallel at the boundary root:
async function ReviewsAndRatings({ productId }: { productId: string }) {
// Both promises start immediately. React 19's `use` unwraps them
// where they're actually needed.
const reviewsPromise = getReviews(productId);
const ratingsPromise = getRatings(productId);
return (
<>
<RatingsSummary ratingsPromise={ratingsPromise} />
<ReviewsList reviewsPromise={reviewsPromise} />
</>
);
}
Then inside the children, use(reviewsPromise) and use(ratingsPromise). One boundary, parallel fetches, no waterfall.
How we decide where a boundary goes
A rough checklist we run through during code review:
- Is this component part of the LCP element? → No boundary.
- Is it above the fold and fast (<50ms server work)? → No boundary.
- Is it above the fold and slow? → Fix the data, or accept a stable fallback with matched dimensions.
- Is it below the fold? → Boundary, almost always.
- Does it depend on request-specific data (cookies, headers)? → Boundary, so the shell can stay cacheable.
- Would streaming it delay a form submission or navigation? → Reconsider — server actions and Suspense interact in non-obvious ways.
That last point deserves its own note. When a server action revalidates a route, Suspense boundaries re-suspend. If your boundary is around the primary content that the user just interacted with, you'll flash a skeleton on submit. Sometimes that's fine, sometimes it's jarring. Test it on a throttled connection before shipping.
Measuring whether you got it right
Don't trust local dev. Local dev lies about everything performance-related. What we actually look at:
- CrUX / RUM data for LCP, CLS, and INP over a 28-day window. Field data is the only data that matters.
- WebPageTest filmstrips on a Moto G Power profile with 4G throttling. If the LCP element paints in the first frame after HTML arrives, boundaries are placed correctly.
- Chrome DevTools Performance panel, filtered to the "Streaming" track added in recent Chromium builds. You can see exactly when each chunk arrives and which component it hydrated.
One number we've learned to distrust: Lighthouse LCP scores on a page that streams. Lighthouse can misattribute the LCP element to the fallback if the swap happens close to the paint. Cross-check with real device data.
Where we'd start
If you inherit a Next.js codebase and want to improve streaming without a full rewrite:
- Open the three highest-traffic routes. Find every
<Suspense>. Ask, for each one, whether the wrapped content is part of the LCP element. Remove boundaries that wrap LCP content. - Audit every fallback for dimension matching. Fix the ones that cause layout shift.
- Add boundaries around anything below the fold that currently blocks the response — reviews, recommendations, feeds, comment threads.
- Move the slow, non-critical fetches out of the page component and into the streamed children, kicking off promises in parallel with
use. - Ship, wait a week, look at CrUX.
Done in that order, this is usually a two-day exercise that moves LCP by a meaningful amount on real users' devices. If you want a second pair of eyes on your App Router setup, our team does this kind of audit as part of our web development work.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

React Server Components and Third-Party Scripts: The Hydration Trap Nobody Warned Us About
Analytics, chat widgets, and A/B testing tools quietly break React Server Components in ways that don't show up until production. Here's the pattern we use to keep them from poisoning hydration and TTI.

Cache Tags in Next.js: How We Stopped Nuking the Whole Site on Every Publish
revalidateTag looks simple until your CMS editor clicks Publish and half the site rebuilds. Here's how we tag, invalidate, and debug cache in a real App Router app.

Route Handlers vs Server Actions for Public APIs: Pick the Right Door
Server actions look tempting for every mutation, but they're the wrong front door for public APIs. Here's the rule we use in production, and the failure modes that taught us the difference.
