Streaming Suspense Boundaries: Why Your TTFB Looks Great and Your LCP Still Sucks
Streaming SSR in the Next.js App Router makes TTFB look amazing. But if your Suspense boundaries are drawn wrong, LCP gets worse, not better. Here's how we diagnosed and fixed it on a real site.

A product manager pinged us last quarter with a familiar complaint: "Lighthouse says our TTFB is 180ms, but the page still feels slow and LCP is 3.4s." The site was on Next.js 15, App Router, streaming SSR, the whole modern stack. The problem wasn't the server. It was where we'd drawn our Suspense boundaries.
This is the kind of bug that doesn't show up until you ship. Here's how to spot it, how to fix it, and the mental model we use now when deciding what to wrap in <Suspense>.
What streaming actually buys you
When you use the App Router with async server components, Next streams the HTML response in chunks. The browser gets the shell immediately, and Suspense fallbacks get replaced with real content as server data resolves. That's why TTFB looks fantastic — the first byte really does arrive fast.
But TTFB is a server metric. LCP is a user-perceived metric. It measures when the largest visible element in the viewport is painted. If the thing that counts as your LCP element is inside a Suspense boundary that waits on a slow database query, your LCP is bounded by that query — no matter how fast the shell streams.
This is the trap: streaming makes the page feel progressive, which makes developers assume the critical content is also fast. It often isn't.
A quick refresher on how boundaries work
Everything above a <Suspense> boundary streams first. Everything inside waits for its async children to resolve, then streams as a separate chunk, replacing the fallback. React 19 and the App Router handle the out-of-order streaming for you via inline <script> tags that patch the DOM.
So the question of "what is LCP" becomes: which chunk contains the largest contentful element, and when does that chunk arrive?
The war story
The page in question was a product detail page. Rough structure:
// app/products/[slug]/page.tsx
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
return (
<main>
<Header />
<Suspense fallback={<ProductSkeleton />}>
<ProductHero slug={slug} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={slug} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations slug={slug} />
</Suspense>
</main>
);
}
Looks reasonable. Three independent sections, three boundaries, each streams on its own schedule. Textbook App Router.
The ProductHero contained the product image — the LCP element. It also made a call to an internal pricing service that, under load, took 600–900ms. The hero image URL came from the same payload.
Result: the shell streamed in 180ms, the user saw a skeleton for roughly a second, then the hero (and the LCP image) painted. LCP: ~3.2s on a mid-tier Android, worse on slow 4G because the image download started only after the HTML chunk arrived.
The fix wasn't to remove the Suspense boundary. It was to move the LCP-critical bits out of it.
The mental model: draw boundaries around slowness, not around components
The instinct is to wrap every data-fetching component in <Suspense>. Don't. Wrap only the parts that are both slow and non-critical to first paint.
For every section of a page, ask three questions:
- Is this element above the fold on a typical viewport?
- Is it a candidate for being the LCP element (large image, large text block, hero video poster)?
- Does its data source have predictable sub-200ms latency?
If the answer to 1 or 2 is yes, you want that data resolved before the first chunk goes out, so the LCP element is in the initial HTML. That means no Suspense boundary around it, or a boundary drawn tightly around only the slow children.
The refactor
We split the hero. The image URL and title came from a cached catalog service (sub-50ms). The dynamic price came from the slow pricing service. We moved the Suspense boundary to wrap only the price.
// app/products/[slug]/page.tsx
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await getProduct(slug); // fast, cached
return (
<main>
<Header />
<ProductHero product={product}>
<Suspense fallback={<PriceSkeleton />}>
<LivePrice sku={product.sku} />
</Suspense>
</ProductHero>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={slug} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations slug={slug} />
</Suspense>
</main>
);
}
Now the hero image is in the initial HTML chunk. The browser's preload scanner sees the <img> immediately and starts downloading. The price swaps in a moment later with a tiny skeleton that doesn't shift layout.
LCP on the same device/network dropped into the 1.6–1.9s range in our testing. TTFB went up slightly (from ~180ms to ~230ms) because the initial render now waits on the catalog fetch. That's a trade we'll take every time.
Preloading: the other half of the story
Moving the image into the shell helps, but you can do better. If you know the hero image URL at request time, hint the browser:
import ReactDOM from 'react-dom';
export async function ProductHero({ product, children }: Props) {
// Preload hint flushed with the shell
ReactDOM.preload(product.heroImageUrl, {
as: 'image',
fetchPriority: 'high',
});
return (
<section>
<img
src={product.heroImageUrl}
alt={product.name}
fetchPriority="high"
width={1200}
height={800}
/>
<h1>{product.name}</h1>
{children}
</section>
);
}
ReactDOM.preload emits a <link rel="preload"> into the streamed <head>, so the browser starts fetching the image in parallel with the rest of the HTML. Combine that with fetchPriority="high" on the <img> itself and the Next <Image> component's native lazy-loading opt-out (priority prop), and you've given the browser everything it needs.
A note on next/image
If you use next/image, set priority on your LCP image. It applies fetchPriority="high", disables lazy loading, and emits a preload. Without it, the component defaults to lazy, which is almost always wrong for an above-the-fold hero.
How to diagnose this yourself
You don't need fancy tooling. Three steps:
- Run Lighthouse in incognito on throttled 4G. Not your dev machine on Wi-Fi. Look at the LCP element it identifies.
- Open DevTools → Performance → record a cold load. Find the LCP marker. Trace back to when the HTML chunk containing that element arrived.
- Compare to TTFB. If LCP is more than ~1.5s later than TTFB on a fast connection, you're probably streaming your LCP element in a late chunk.
For production monitoring, pipe the web-vitals library's attribution build into your analytics. The attribution data tells you which element was the LCP element, and the sub-metrics (TTFB, resource load delay, resource load time, render delay) show you where in the pipeline the time went.
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
const { element, url, timeToFirstByte, resourceLoadDelay } =
metric.attribution;
sendToAnalytics({
value: metric.value,
element,
url,
ttfb: timeToFirstByte,
loadDelay: resourceLoadDelay,
});
});
A high resourceLoadDelay on your LCP image means the browser didn't learn about the image early enough. That's the signature of a Suspense boundary drawn too wide.
When streaming into a boundary is right
To be clear, we're not against Suspense boundaries. For below-the-fold content, slow personalization, A/B tested blocks, user-specific widgets, comment threads, recommendation carousels — wrap them. Streaming is genuinely great for those. The user sees a fast, interactive shell and the slow stuff fills in.
The rule we use internally: Suspense boundaries are for content the user won't notice loading late. If they will notice — because it's the hero, the headline, the primary CTA, or the LCP image — resolve it before the shell goes out.
Where we'd start
If you've got a Next.js App Router app and your LCP is worse than you'd expect given your TTFB, do this in order:
- Identify your LCP element with Lighthouse or web-vitals attribution. Don't guess.
- Check whether it lives inside a Suspense boundary. If it does, ask whether the slow dependency is the LCP element itself or a sibling.
- If it's a sibling, split the component so only the slow part is suspended.
- Add
ReactDOM.preloadorpriorityto the LCP asset so the browser fetches it in parallel with the HTML. - Re-measure on a throttled connection, not your laptop.
Streaming SSR is a sharper tool than it looks. Use it to hide slowness the user doesn't care about, not to hide the one element they're waiting to see. If you want a second pair of eyes on a Core Web Vitals regression, that's exactly the kind of thing our web development team spends its days on.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks
Our OpenGraph images looked perfect in local dev and broken in every Slack preview. Here's the postmortem on dynamic `og:image` routes, Satori fonts, caching headers, and the bot UA quirks nobody warns you about.

Request Memoization in the App Router: The Hidden Cost of Fetching the Same Thing Twice
Next.js memoizes fetch() within a request, but the rules are stricter than most teams realize. Here's what actually dedupes, what silently doesn't, and how to stop paying for the same database call four times per page.

React 19's `use` Hook in Server Components: The Waterfall We Didn't See Coming
The `use` hook promised cleaner data fetching in React 19. Then our TTFB doubled. Here's what went wrong, how we spotted it, and the patterns that actually work in Next.js App Router.
