Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is not a free win. Put Suspense in the wrong place and you'll ship a page that feels slower than the blocking version. Here's how we decide where the boundaries go.
Streaming SSR is the feature everyone name-drops and almost nobody tunes. Wrap the wrong subtree in <Suspense> and you'll happily ship a page where TTFB looks great, LCP regresses by 400ms, and the marketing team asks why the hero flickers on every navigation. The boundary itself is the API — get it wrong and streaming works against you.
This is the mental model we use on production Next.js App Router work, and the specific gotchas we've hit shipping it.
What streaming actually buys you
With the App Router, the server starts flushing HTML the moment the shell is ready. Anything wrapped in <Suspense> gets a placeholder in that initial flush, and its real HTML arrives later on the same response, injected via inline <script> tags that swap the fallback for the real content.
The wins are real but narrow:
- Faster TTFB and FCP, because the shell doesn't wait on slow data.
- Parallel data fetching across sibling Suspense boundaries without you writing a
Promise.all. - Better perceived performance when the streamed part is genuinely below the fold.
The cost is also real:
- Content that arrives late causes layout shift if you didn't reserve space.
- LCP candidates that sit inside a Suspense boundary get their paint deferred.
- The response stays open longer, which interacts badly with some CDNs and buffering proxies.
So the question isn't "should we stream?" It's "which subtrees deserve a boundary, and which should block the shell?"
The rule we actually use
Here's the heuristic we apply on almost every route:
Block the shell on anything that is (a) above the fold, (b) a likely LCP element, or (c) needed for correct layout. Stream everything else.
That sounds obvious. In practice teams violate it constantly because the App Router examples in tutorials wrap everything in Suspense, including the hero. Don't.
A concrete layout
Consider a product detail page. Roughly:
- Header (nav, cart count)
- Hero: product image, title, price, buy button
- Specs table
- Reviews
- "You might also like" carousel
Only one of these is the LCP candidate: the hero image. Two of them are slow: reviews (aggregations, moderation flags) and recommendations (a separate ranking service). The specs table is fast because it lives on the same row as the product.
Here's how we'd wire it:
// app/product/[slug]/page.tsx
import { Suspense } from 'react';
import { getProduct } from '@/lib/product';
import { Reviews, ReviewsSkeleton } from './reviews';
import { Recommendations, RecoSkeleton } from './recommendations';
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const product = await getProduct(slug); // blocks the shell — intentional
return (
<article>
<ProductHero product={product} />
<SpecsTable specs={product.specs} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecoSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</article>
);
}
The hero is inside the shell. It has to be — it's the LCP element and it defines the fold. Reviews and recommendations each get their own boundary so they stream in parallel. If reviews take 800ms and reco takes 500ms, the user sees both when they're ready, and the browser paints the hero at roughly TTFB + hero image download.
Where teams get this wrong
Wrapping the whole page in one Suspense
We've seen this pattern more than once:
// don't do this
<Suspense fallback={<PageSkeleton />}>
<EverythingAsync />
</Suspense>
This defeats the point. You've told React "render nothing until all data is ready," which is exactly what blocking SSR did. Worse, you've replaced a real HTML shell with a skeleton, so search engines see less content on first flush and users see a loading state that they wouldn't have needed.
Putting the LCP element behind a boundary
If the hero image URL comes from an experimentation service, teams sometimes stream that decision. Now the LCP element depends on a script arriving mid-stream. In our experience this can push LCP well past the 2.5s threshold on mid-tier Android, even when the rest of the page is fast.
Fix: resolve the experiment on the shell, or use a stable default image and swap client-side after hydration. The boundary belongs around the decoration, not the LCP.
Fallbacks that don't match final dimensions
A ReviewsSkeleton that's 200px tall replaced by real reviews at 900px tall will trigger CLS. Every skeleton should reserve the maximum plausible height for its slot, or use min-height with a scrolling internal container.
export function ReviewsSkeleton() {
return (
<section aria-busy="true" style={{ minHeight: 720 }}>
{/* shimmer rows */}
</section>
);
}
Nesting Suspense boundaries you didn't plan
A child component doing its own <Suspense> inside a parent boundary means the parent's fallback shows first, then the child's fallback shows inside it, then the real content. On slow networks users perceive this as two separate loading states for one region. If the child is always rendered together with the parent, hoist the data fetch up and use one boundary.
Measuring whether streaming is actually helping
Boundaries are a hypothesis. Verify with real numbers, not vibes.
We look at three signals:
- TTFB from the edge, not from
curlon your laptop. Use RUM (Vercel Analytics, or a self-hostedweb-vitalsreporter). - LCP element identity.
PerformanceObserveronlargest-contentful-painttells you which element won. If it's inside a Suspense boundary, that boundary is probably in the wrong place. - CLS around each streamed region. If a section causes CLS > 0.05 on its own, the skeleton is lying about the final size.
A quick client hook we drop into staging builds:
// lib/lcp-debug.ts
export function logLcpElement() {
if (typeof PerformanceObserver === 'undefined') return;
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1] as PerformanceEntry & {
element?: Element;
startTime: number;
};
// eslint-disable-next-line no-console
console.log('[LCP]', last.startTime.toFixed(0), 'ms', last.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
}
Run it against a throttled 4G profile. If LCP consistently lands on an element inside a Suspense boundary, move the boundary or move the element.
Streaming and the platform edges
A few production notes that don't show up in tutorials:
CDN buffering can silently kill streaming
Some CDNs and load balancers buffer the response until it's complete unless you set the right headers or use their edge runtime specifically. If you see "streaming works locally but not in production," this is the first place to look. Check the Transfer-Encoding: chunked header on the response and confirm the CDN isn't holding it.
Server actions inside streamed subtrees
A form rendered inside a Suspense boundary is fine, but its server action serializes the boundary's props on submit. Keep those props small — see our note on server actions under load for the fuller picture on payload cost.
loading.tsx is a Suspense boundary
The file-based loading.tsx wraps your page in an implicit <Suspense>. If you already have a well-placed boundary inside page.tsx, adding a loading.tsx may create a redundant outer fallback that flashes on navigation. Pick one layer to own the loading UI per route.
Error boundaries pair with Suspense boundaries
A subtree that can suspend can also throw. Pair each meaningful Suspense with an error.tsx or a local <ErrorBoundary>. Otherwise a downstream failure in the reviews service takes out the whole page after the shell has already streamed — a confusing UX where content vanishes mid-scroll.
Where we'd start
If you're auditing an existing App Router codebase this week:
- On your three highest-traffic routes, list every
<Suspense>boundary and note which subtree it wraps. - For each, answer: is this subtree above the fold, and could it contain the LCP element? If yes, remove the boundary and let it block the shell.
- For every remaining boundary, check the skeleton's rendered height against the real content's height on a mid-tier phone. Fix the ones that shift.
- Add a
PerformanceObserverlog in staging and confirm the LCP element is not inside any boundary.
Streaming SSR rewards teams who treat Suspense boundaries as a design decision, not a default. Put them where slow, non-critical content lives — and nowhere else.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Route Handlers vs Server Actions for Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
The Client Bundle Creep: How Third-Party SDKs Quietly Doubled Our JS Payload
A war story about how six innocent-looking SDKs turned a lean Next.js App Router build into a 480 KB client bundle, and the audit playbook we now run before every launch.
