Short answer
Streaming lets Next.js send the page shell before slow data resolves, so TTFB tracks how long the shell takes to render, not how long your slowest query takes. Each <Suspense> boundary is both a streaming point and a hydration unit. The cost is that the status code and headers are fixed once the first byte goes out, and any proxy that buffers the response quietly undoes the benefit.
Streaming is on by default in the App Router. What goes wrong is rarely the API. It comes from three places: where the boundaries sit, what happens to HTTP once the stream has started, and whether the infrastructure between the server and the browser actually passes chunks through. This article covers those three, checked against the Next.js 16 documentation (16.4 is the current release at the time of writing).
If you need the basics of loading.tsx and layouts first, start with Next.js App Router patterns. This article picks up from there.
What actually changes when a response streams
In blocking server rendering, the server renders the whole document and then sends it. One slow call holds back every byte. With streaming, React renders the parts that are ready, sends them, and keeps the connection open. On HTTP/1.1 this uses chunked transfer encoding. HTTP/2 and HTTP/3 have no chunked encoding because they send data frames natively, but the effect is the same.
A streamed App Router page is delivered in this order:
- The shell. Layouts, navigation, anything rendered above a Suspense boundary, and the fallbacks. The
<link>and<script>tags are in this first chunk, so the browser starts fetching CSS and JavaScript while the server is still working. - Resolved boundaries, in completion order. When an async Server Component finishes, React streams its HTML in a hidden container, plus a small inline script that swaps it in for the fallback. The swap does not wait for your JavaScript bundle or for hydration.
- The component payload. This is the serialised tree React uses to hydrate. It is embedded in the same stream.
The Next.js streaming guide sums up the Web Vitals effect: without streaming, TTFB equals the slowest query. With streaming, it is the time to render your layouts and fallbacks. Google’s TTFB guidance rates 0.8 seconds or less as good, and it counts an early flush as the first byte. So streaming improves the metric as measured, not just how fast the page feels.
Where to put Suspense boundaries
The original advice still holds: wrap slow, non-critical components in their own boundary. What has changed is how precise you have to be about it.
// app/product/[id]/page.tsx (Next.js 15+: params is a Promise)
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/catalogue';
import { ProductDetails } from './product-details';
import { Recommendations } from './recommendations';
import { RecommendationsSkeleton } from './recommendations-skeleton';
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id); // fast, cached, and the LCP content
if (!product) notFound(); // a real 404: nothing has streamed yet
return (
<main>
<ProductDetails product={product} />
{/* Slow and personalised: streams in without delaying the product */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</main>
);
}
There are three rules behind that shape:
- Keep the LCP element above every boundary. If the hero image or main heading sits inside a Suspense boundary, it cannot paint until that boundary resolves and its swap script runs. Streaming a fast shell around a delayed LCP element makes TTFB look better while LCP gets no better.
- Use sibling boundaries for independent data and nested boundaries for dependent data. Siblings resolve in whatever order their data arrives. Nesting gives you a staged reveal, so reviews appear only after the product details.
- Push dynamic access down. An
await cookies()orawait paramsat the top of a layout makes everything below it wait. Pass the promise to the component that needs the value and resolve it inside that component’s boundary.
Do not add boundaries you do not need. The Next.js docs warn that under a slow network or a busy CPU, React may show a fallback even when you didn’t expect it. Every boundary is a place where the user might see a skeleton.
loading.tsx or explicit Suspense?
loading.tsx |
<Suspense> |
|
|---|---|---|
| Scope | Whole route segment (wraps page, not-found and nested layouts) |
Any component |
| Prefetched on navigation | Yes, shown instantly | Not by default |
Wraps the segment’s own layout.tsx? |
No | Wherever you put it |
| Best for | Pages with nothing meaningful to show until data resolves | Most pages: fast content first, slow sections later |
A detail that catches people out: loading.tsx does not wrap the layout in the same folder. If that layout reads cookies or does an uncached fetch, navigation blocks until it finishes, and the loading state never appears. Move the dynamic read into the page, or give it its own boundary inside the layout.
Selective hydration: where “progressive hydration” comes from
React’s name for this is selective hydration. Each Suspense boundary is hydrated independently. React hydrates boundaries as their code and data arrive, and it moves a boundary up the queue if the user interacts with it. Without boundaries, the page hydrates in one long task. With boundaries, hydration is split into smaller tasks that give the main thread back to the browser between them.
That makes boundary placement an INP decision as well as a loading decision. A page with one giant client tree and no boundaries can stream its HTML perfectly and still freeze on the first tap. For the main-thread side of this, see the INP deep dive on long tasks and yielding.
Two related habits help:
- Keep the
'use client'boundary as low as possible, so less JavaScript has to hydrate at all. - For data a Client Component needs, start the fetch on the server and pass the unresolved promise down. The client reads it with React’s
use()inside a boundary. The server is not blocked, and the client does not run a fetch of its own.
// Server Component: start the work, don't await it
const statsPromise = getStats();
return (
<Suspense fallback={<ChartSkeleton />}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
);
// stats-chart.tsx
'use client';
import { use } from 'react';
export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
const stats = use(dataPromise);
return <Chart data={stats} />;
}
The HTTP contract: status codes are decided early
The original draft got this right, and it matters more than anything else on this page. When the first chunk is sent, the status line and headers go with it. After that, nothing can change them.
- A streamed page starts with
200. - If
notFound()fires inside a boundary after streaming has started, Next.js cannot send a 404. It injects<meta name="robots" content="noindex">into the HTML instead. Some crawlers will call that a soft 404. - A
redirect()after streaming has started becomes a client-side redirect, not an HTTP 3xx. - An error thrown in a streamed component is caught by the nearest
error.tsx, which replaces only that section. The response stays200. Your error monitoring must catch it, because your access logs will not show it.
The fix is ordering. Do existence and permission checks before the first await that sits under a boundary, as the product example does. Or do them before rendering starts, with a cheap check in proxy or a next.config.js redirect. Rendering begins at the first fallback or the first suspending component under a boundary, and any notFound(), forbidden() or unauthorized() called before that keeps its real status.
Metadata and crawlers
Next.js detects HTML-limited bots by user agent and resolves generateMetadata before streaming, so for those bots the title and canonical are in the initial <head>. Full browsers and DOM-capable crawlers can receive streaming metadata alongside the page. The list is configurable with htmlLimitedBots. The practical risk is with Cache Components: an HTML-limited bot skips the prerendered shell and renders the page dynamically. If the shell relies on data that exists only at build time, the bot can get an error that real visitors never see. Make sure that data is also available at request time.
Partial Prerendering and Cache Components
In Next.js 16, Partial Prerendering is no longer a separate experimental flag. experimental.ppr was removed and its behaviour is now the default rendering model under cacheComponents: true. New apps from create-next-app have it on. With it enabled:
- Everything that can be prerendered, including cached data marked
'use cache', goes into a static shell built at build time and served from the CDN. - Runtime data (
cookies(),headers(), uncached fetches) must sit inside a Suspense boundary. If it doesn’t, Next.js shows a blocking-route error during development and the build fails. - The fallbacks are part of the static shell, and the dynamic parts stream in from the server.
That changes how you think about TTFB. With Cache Components the shell is no longer rendered per request: it is a cached file, and the server only has to stream the gaps. Choosing what to cache is now part of streaming design. The four cache layers you are working with are covered in Next.js caching explained.
Infrastructure that silently breaks streaming
All of the above assumes chunks reach the browser as they are produced. Any layer that buffers the full response turns streaming back into a blocking render with extra steps.
| Layer | Symptom | Fix |
|---|---|---|
| nginx (and similar) | Whole page arrives at once | Send X-Accel-Buffering: no from Next.js, or set proxy_buffering off for the upstream |
| CDN | Shell delayed until the slowest boundary finishes | Check the provider supports passing through streamed responses. Some need configuration or a specific plan |
| Serverless | No progressive rendering | AWS Lambda needs response streaming turned on explicitly |
| Compression | First chunk arrives late | Check that gzip/Brotli flushes regularly instead of waiting to fill its buffer |
| Safari / WebKit | Tiny demo responses don’t stream | Buffers until 1024 bytes. Real pages pass this easily |
// next.config.ts - the header approach from the Next.js self-hosting guide
const nextConfig = {
async headers() {
return [
{ source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }] },
];
},
};
export default nextConfig;
To confirm streaming works end to end, open the document request in Chrome DevTools. An early TTFB followed by a long “Content Download” phase means it is streaming. For something more exact, read the body with a small Node script that sends Accept-Encoding: identity and logs a timestamp for each chunk. curl buffers too, so it is not a reliable test. Container and proxy setup for self-hosting is in Deploying Next.js: Vercel, self-hosted and edge.
A checklist before you ship a streamed route
- The LCP element renders outside every Suspense boundary.
- Each skeleton matches the size of the content it replaces, or sits in a container with a minimum height. Otherwise the swap causes layout shift.
- Existence and authorisation checks run before the first boundary, so 404, 401 and 403 are real status codes.
- Every streamed section has an
error.tsx(or component-level boundary) and errors are reported to monitoring, not just rendered. - No top-level
awaitoncookies(),headers()orparamsin shared layouts. - You have tested the production path through your real proxy and CDN, not just
next dev.
Questions people actually ask
- Does streaming hurt SEO?
- Not by itself. The HTML is still server-rendered, and Next.js holds metadata back for HTML-limited bots so it lands in the initial
<head>. The real SEO risk is status codes. A 404 that fires after streaming has started is a 200 with anoindextag, so check that content exists before the first boundary. - Why does my page still arrive all at once in production?
- Something between Next.js and the browser is buffering, usually nginx, a load balancer, a CDN or the compression layer. Set
X-Accel-Buffering: no(orproxy_buffering off) and test with a chunk-logging script rather thancurl. - Why doesn’t my loading.tsx show when I navigate?
- The layout in the same segment is probably reading cookies, headers or uncached data.
loading.tsxdoes not wrap that layout, so navigation waits for it. Move the read into the page, or wrap it in its own<Suspense>inside the layout. - Is Partial Prerendering still experimental?
- Not as a separate flag. Next.js 16 removed
experimental.ppr, and partial prerendering is the default behaviour whencacheComponentsis enabled. That is the default for new apps.
Streaming in dev but blocking in production?
I review App Router codebases and their hosting setup, covering boundary placement, status-code handling and the proxies and CDNs that quietly buffer responses, and I send back a prioritised list of fixes.