React & Next.js · 11 min read

Streaming in Next.js: Suspense, Selective Hydration and TTFB

Where Suspense boundaries belong, what streaming does to TTFB and status codes, and the infrastructure that silently turns it off.

By Praful Patel

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:

  1. 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.
  2. 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.
  3. 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() or await params at 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 stays 200. 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 await on cookies(), headers() or params in 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 a noindex tag, 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 (or proxy_buffering off) and test with a chunk-logging script rather than curl.

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.tsx does 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 when cacheComponents is 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.

Request a Next.js rendering review →
← Back to all insights