Streaming & Suspense

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. Streaming is an App Router feature: everything here is about the App Router, and the last section says what the Pages Router does instead. Where caching changes the behaviour, the page says which model it means, the previous model (the default, in which nothing is cached unless you ask) or the opt-in Cache Components model (cacheComponents: true). Every example was built with next build, started with next start and observed over HTTP with Next.js 16.3.8 and Node.js 24.16 on macOS, in a scratch copy of the Bookshelf that the rest of this section grows; each one is followed by the official page it derives from, and the chunk timings quoted below are real measurements from those runs.

Streaming is the mechanism behind almost everything else in the rendering chapters. Introduction and Architecture placed it on the rendering spectrum, the sibling pages on rendering fundamentals, server-side rendering and hydration build on it, and this page is the one that opens the box: what bytes leave the server, in what order, what the protocol allows you to change once the first byte is out, and what can go wrong between the server and the browser.

What Streaming Is, and Why It Exists

In classic server-side rendering the server produces the whole HTML document before it sends anything, so one slow query delays the entire page: the time to first byte equals the slowest piece of data. Streaming uses HTTP chunked transfer encoding to send the response in parts as they become ready. React’s server renderer cuts its HTML output along <Suspense> boundaries, and Next.js plugs that into the App Router, so no configuration is needed: you opt in by placing boundaries (explicitly with <Suspense>, or implicitly with a loading.js file).

Without streaming With streaming

First byte

After the slowest data has resolved

After the layouts and fallbacks have rendered (milliseconds)

What the browser can do early

Nothing, the document is not there yet

Parse the head, start fetching CSS, JavaScript and fonts, paint the shell

Slow section

Delays everything

Delays only its own boundary

HTTP status and headers

Can depend on any data

Fixed once the first chunk is sent (see The HTTP Contract: Status and Headers Are Fixed After the Shell)

Streaming does not make slow data faster. It stops slow data from holding back the rest of the page, and it moves the work to the earliest moment: resources are discovered and fetched while the server is still thinking.

The Two Streams: HTML Chunks and the Inline RSC Payload

On the initial page load, two streams travel together in one HTTP response.

  • The HTML stream is what the user sees. The static parts of the page (layouts, navigation and the fallbacks of every <Suspense> boundary) are rendered and sent first. When a boundary’s content becomes ready, for example an async Server Component resolves, React streams its finished HTML together with a tiny inline <script> that swaps the fallback for the content. The swap needs no framework JavaScript: the browser runs the inline script as soon as it arrives, before the page’s bundles have loaded or hydration has finished.

  • The component payload (the RSC payload, see the page on Server and Client Components) is a serialised description of the component tree. React needs it to hydrate the page and to update it later. On the initial load it is embedded in the same HTML stream, as self.__next_f.push(…​) scripts that sit next to the HTML of each boundary. On a client-side navigation only the payload is fetched (a request carrying the rsc: 1 header) and no HTML is transferred at all.

The static shell is everything that renders before any async work resolves. Under the previous model it is produced per request; under Cache Components it is prerendered at build time and can be served instantly from a CDN, while the dynamic holes stream in from the server.

What the Wire Looks Like

The dashboard of the next section has two sibling boundaries, one resolving after one second and one after two and a half. Reading the raw response shows the structure. The first chunk contains the head, the layout, <h1> and the two fallbacks, each marked with a <template id="B:n"> placeholder:

<main><h1>Bookshelf dashboard</h1><!--$?--><template id="B:0"></template><p>Loading reviews...</p><!--/$-->
<!--$?--><template id="B:1"></template><p>Loading related books...</p><!--/$--></main>

About a second later the next chunks arrive: first the payload for the finished section, then a hidden <div> with the completed HTML and a call that swaps it in place of the fallback marked B:0:

<script>self.__next_f.push([1,"5:[\"$\",\"section\",null,{\"id\":\"reviews\",\"children\":[\"Reviews: \",\"Great, Classic\"]}]\n"])</script>
<div hidden id="S:0"><section id="reviews">Reviews: <!-- -->Great, Classic</section></div>
<script>/* helper functions $RB, $RV, $RC are defined once here */ $RC("B:0","S:0")</script>

The markers B:n (boundary) and S:n (segment) pair a fallback with its replacement, which is why boundaries can resolve in any order. The same document ends only when the last boundary has been flushed (</body></html> is the final chunk).

Source: Streaming (sections "How the App Router delivers a page" and "Verifying that streaming works").

Seeing the Chunks Arrive

Do not trust streaming you have not observed. A page can be generated progressively on the server and still reach the user in one piece because something in between buffers it (see What Can Break Streaming: Proxies, CDNs, Compression and Clients). Three tools help.

The browser. In the Network tab of DevTools, select the document request and open Timing: an early "Waiting for server response" (time to first byte) followed by a long "Content Download" means the response is streaming.

curl --no-buffer. curl buffers its own output, --no-buffer (-N) turns that off. It still flushes by lines, so a stream without newlines may look stalled even with -N. The write-out variables measure the contract of streaming directly, time to first byte against total time:

curl --no-buffer -s -o /dev/null \
  -w 'ttfb=%{time_starttransfer}s total=%{time_total}s status=%{http_code}\n' \
  http://localhost:3404/shelf/dashboard
ttfb=0.014832s total=2.523194s status=200

The first byte arrived after 15 ms, the last one after 2.5 s, with 200 on the first line of the response. For a stream that does contain newlines, a Route Handler (see Streaming Outside React: Route Handlers), curl can timestamp each line:

curl --no-buffer -s http://localhost:3404/api/stream \
  | perl -MTime::HiRes=time -ne 'BEGIN{$t=time} printf "+%4dms  %s", (time-$t)*1000, $_'
+  14ms  Chunk 1
+ 213ms  Chunk 2
+ 414ms  Chunk 3
+ 616ms  Chunk 4
+ 816ms  Chunk 5

A script that reads the body as a stream. This is the most reliable way to see the chunks of an HTML page and the one the official guide recommends. Accept-Encoding: identity switches compression off, so that the compression layer does not buffer the chunks.

// observe.mjs, run with: node observe.mjs <url> [user-agent]
const [url, ua] = process.argv.slice(2);
const headers = { "Accept-Encoding": "identity" };
if (ua) headers["User-Agent"] = ua;

const start = Date.now();
const res = await fetch(url, { headers });
console.log(`status ${res.status}, headers after +${Date.now() - start}ms`);

const reader = res.body.getReader();
let i = 0;
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(`chunk ${i++} (+${Date.now() - start}ms) ${value.length} bytes`);
}

Run against the dashboard (previous model), it prints:

status 200, headers after +51ms
chunk 0 (+52ms) 7652 bytes      <- shell: head, layout, h1, two fallbacks
chunk 1 (+1040ms) 140 bytes     <- payload of the reviews section
chunk 2 (+1040ms) 952 bytes     <- hidden div S:0 and the swap script
chunk 3 (+2540ms) 146 bytes     <- payload of the related section
chunk 4 (+2541ms) 143 bytes     <- hidden div S:1 and the swap script, </html>

Under Cache Components the shell is prerendered, and the same page arrives as two quick chunks (the prelude and the postponed part, 1670 and 6103 bytes at +82 ms and +102 ms in the same run) followed by the same two boundary flushes at about +1090 ms and +2590 ms.

sequenceDiagram autonumber participant B as Browser participant S as Next.js server B->>S: GET /shelf/dashboard Note over S: t = 0 ms, render layout, h1 and the two fallbacks S-->>B: t = 51 ms, status 200 and headers S-->>B: t = 52 ms, chunk 0, the static shell (7652 bytes) Note over B: parses the head, fetches CSS and JS, paints two skeletons Note over S: reviews query running, related query running S-->>B: t = 1040 ms, chunks 1 and 2, reviews payload and hidden div S:0 Note over B: inline script swaps B:0 for S:0, no framework JS needed S-->>B: t = 2540 ms, chunks 3 and 4, related payload and hidden div S:1 Note over B: inline script swaps B:1 for S:1, response ends
Figure 1. Chunks of the dashboard response over time (previous model, milliseconds after the request)

The Example Routes Used on This Page

All the routes of this page live under a shelf folder of the scratch app. They share one helper that stands in for a slow database or API, and each slow component calls connection() (from next/server) so that it renders at request time instead of being prerendered with fixed data:

// lib/slow.ts
export function slow<T>(ms: number, value: T): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
// app/shelf/dashboard/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { slow } from "@/lib/slow";

async function Reviews() {
  await connection(); // opt this component into request-time rendering
  const reviews = await slow(1000, ["Great", "Classic"]);
  return <section id="reviews">Reviews: {reviews.join(", ")}</section>;
}

async function Related() {
  await connection();
  const related = await slow(2500, ["Neuromancer", "Solaris"]);
  return <section id="related">Related: {related.join(", ")}</section>;
}

export default function Dashboard() {
  return (
    <main>
      <h1>Bookshelf dashboard</h1>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Reviews />
      </Suspense>
      <Suspense fallback={<p>Loading related books...</p>}>
        <Related />
      </Suspense>
    </main>
  );
}

Dashboard itself is synchronous and awaits nothing, so the heading and both fallbacks form the static shell. Each sibling boundary resolves on its own clock: the second does not wait for the first, and the total time is the slowest of the two (2.5 s), not the sum (3.5 s). In a real application replace slow() with a database call, a fetch or a cached function; see the pages on data fetching and caching.

loading.js Versus Granular Suspense Boundaries

A loading.js file next to a page.js makes Next.js wrap the page in a <Suspense> boundary whose fallback is the exported component. The file’s place in the component hierarchy is covered in layouts, templates and special files; what matters here is the effect on streaming.

// app/shelf/page-level/loading.tsx
export default function Loading() {
  return <p>Loading the whole page...</p>;
}
// app/shelf/page-level/page.tsx
import { connection } from "next/server";
import { slow } from "@/lib/slow";

export default async function PageLevel() {
  await connection();
  const titles = await slow(1500, ["Dune", "Neuromancer"]);
  return <main><h1>Page-level</h1><ul>{titles.map((t) => <li key={t}>{t}</li>)}</ul></main>;
}
status 200, headers after +28ms
chunk 0 (+29ms) 7483 bytes       <- layout and the loading.js fallback
chunk 1 (+1525ms) 282 bytes      <- payload of the page
chunk 2 (+1526ms) 1203 bytes     <- the page replaces the skeleton

The layout is part of the shell and paints at once; the whole page content is one boundary, so it appears in one piece after 1.5 s. That is fine when nothing on the page is meaningful without its data. For everything else prefer explicit boundaries close to the data they guard.

Table 1. loading.js versus <Suspense>
loading.js <Suspense>

Scope

The whole page and everything below it in the segment

Any component

Setup

Drop a file next to page.js

Wrap the components explicitly

On navigation

The fallback is prefetched and shown as an instant loading state

Not prefetched by default

Best for

Pages where nothing renders without the data

Most pages, for granular control

Three details decide which one you reach for.

  • loading.js does not wrap the layout. It wraps page.js, not-found.js and nested layouts, but not the layout.js, template.js or error.js of the same segment. If a layout awaits uncached or runtime data (cookies(), headers(), an uncached fetch), the loading file cannot show a fallback for it. Without Cache Components, navigation is blocked until the layout finishes. With Cache Components the build tells you to wrap that access in its own <Suspense>.

  • The navigation behaviour is different. The loading.js fallback is prefetched, so navigating to a dynamic route shows it immediately (unless prefetching has not completed), navigation is interruptible and shared layouts stay interactive. The page on linking and navigation explains prefetching.

  • The prerenderer looks for the nearest boundary. When it meets dynamic work it walks up the tree for a Suspense boundary. A loading.js high in the tree is a valid boundary, so the framework stops there, but the result is a full-page skeleton instead of granular streaming.

Nested Boundaries Give Progressive Detail

Boundaries can be nested. The outer one resolves first and reveals the inner fallback, which gives a layered reveal: for a book page, the header immediately, the details next and the reviews last.

<Suspense fallback={<p>Loading product details...</p>}>
  <ProductDetails id={id} />
  <Suspense fallback={<p>Loading reviews...</p>}>
    <Reviews productId={id} />
  </Suspense>
</Suspense>

Pushing Dynamic Access Down the Tree

The most important rule of streaming is to defer dynamic access to the component that needs it. It applies to params, searchParams, cookies(), headers(), connection() and data fetches. If a layout or page awaits any of them at the top, everything below that point becomes dynamic and cannot be part of the static shell; the user waits for the await before anything below it exists.

Instead of awaiting, start the work, pass the promise down and let the consuming component resolve it behind a boundary:

// app/dashboard/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { Nav } from "./nav";
import { UserMenu } from "./user-menu";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = cookies(); // start the work, but do not await

  return (
    <div>
      <Nav>
        <Suspense fallback={<p>Loading user...</p>}>
          <UserMenu cookiePromise={cookieStore} />
        </Suspense>
      </Nav>
      {children}
    </div>
  );
}

Nav and children are in the static shell because nothing in the layout awaits; only UserMenu suspends when it reads the promise. Had the layout called await cookies() at the top, the whole layout and all its children would be blocked.

The same applies to params and searchParams. This is the pattern the Bookshelf uses for a route whose id is not known at build time:

// app/shelf/missing/[id]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { getBook } from "@/lib/books";
import { slow } from "@/lib/slow";

async function Detail({ paramsPromise }: { paramsPromise: PageProps<"/shelf/missing/[id]">["params"] }) {
  await connection();
  const { id } = await paramsPromise; // read params inside the boundary
  await slow(500, null);
  const book = await getBook(id);
  if (!book) notFound(); // thrown after the shell was sent
  return <h1>{book.title}</h1>;
}

export default function Page({ params }: PageProps<"/shelf/missing/[id]">) {
  return (
    <Suspense fallback={<p>Loading book...</p>}>
      <Detail paramsPromise={params} />
    </Suspense>
  );
}

You can also unwrap the promise inline, so that the child receives a plain value:

<Suspense fallback={<p>Loading products...</p>}>
  {params.then(({ category }) => (
    <ProductGrid category={category} />
  ))}
</Suspense>

Under Cache Components this is not only an optimisation, it is enforced. The first version of the missing page of this experiment awaited params in the page component, outside any boundary, and next build with CACHE_COMPONENTS=1 failed:

Error: Route "/shelf/missing/[id]": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of
`<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

That is the blocking-prerender-dynamic error, and its first fix is the pattern above. Moving the read inside the boundary made the build succeed and the route print as ◐ Partial Prerender. A route whose parameters are known (generateStaticParams) can read params in the page, because the value exists at build time; this is how the next section’s early existence check stays outside the boundary.

The HTTP Contract: Status and Headers Are Fixed After the Shell

An HTTP response starts with the status line and the headers; the body follows. For a stream, the server has to commit to 200 OK and its headers to send the first chunk, and from then on it cannot change them. The consequences all follow from that one fact.

notFound() Mid-Stream

When notFound() is thrown below a <Suspense> boundary or loading.js, the shell (and its 200) has already left. Next.js cannot go back to a 404, so it renders the not-found UI into the stream and injects a robots meta tag so that search engines do not index the URL. The missing route above, requested with an id that does not exist:

curl -s -D- -o /tmp/m.html http://localhost:3404/shelf/missing/99 | head -1
grep -o '<meta name="robots"[^>]*>' /tmp/m.html
HTTP/1.1 200 OK
<meta name="robots" content="noindex"/>

A 200 with noindex is the documented behaviour, not a bug. Some crawlers will label such a page a "soft 404", but because it is explicitly marked noindex it does not lead to indexation.

The same applies to redirect() thrown mid-stream: it cannot send a 307 or 308 header, so it becomes a client-side redirect (a meta refresh in the streamed HTML), and the status stays 200. The page on redirects, rewrites and Proxy has the full table of redirect mechanisms.

Getting a Real 404

The response starts to stream when a Suspense fallback renders (for example a loading.js) or when a component suspends under a boundary. If you need a real 404 for compliance or analytics, make sure the resource is known to exist before that point. Place notFound() before any boundary and before any await that may suspend:

// app/shelf/early/[id]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { connection } from "next/server";
import { getBook } from "@/lib/books";
import { slow } from "@/lib/slow";

async function Detail({ title }: { title: string }) {
  await connection();
  await slow(500, null);
  return <h1>{title}</h1>;
}

export function generateStaticParams() {
  return [{ id: "1" }]; // known params let the shell prerender under Cache Components
}

export default async function Page({ params }: PageProps<"/shelf/early/[id]">) {
  const { id } = await params;
  const book = await getBook(id); // fast existence check
  if (!book) notFound(); // before any Suspense boundary: a real 404
  return (
    <Suspense fallback={<p>Loading book...</p>}>
      <Detail title={book.title} />
    </Suspense>
  );
}
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3404/shelf/early/99
404
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3404/shelf/early/1
200

Keep the existence check fast (an index lookup, not the full content), because nothing is sent while it runs. The other option is to reject earlier still: Proxy (see Proxy) and redirects in next.config.ts run before the page renders, so real status codes are available there, and Proxy can rewrite a missing slug to a not-found route or produce a response itself. Keep those checks cheap too.

flowchart TD A["notFound or redirect is thrown while rendering"] --> B{"Has the first chunk been sent?"} B -- "No: before any loading.js, Suspense fallback or suspending await" --> C["Real status: 404, or 307 and 308 for redirects"] B -- "Yes: below a boundary" --> D["Status stays 200"] D --> E["notFound: not-found UI plus meta robots noindex"] D --> F["redirect: client-side redirect streamed in the HTML"] G["Proxy or next.config redirects"] --> H["Run before rendering, so any status is possible"]
Figure 2. Where each outcome is decided

Errors Mid-Stream

If a component throws after streaming has started, the nearest error.js boundary catches it and the rest of the page stays intact: only the failed section is replaced. The status cannot change either, so an application error in a streamed section is a 200. Monitoring that counts 5xx responses will not see it; log the error on the server (the error and its digest appear in the server log) or in your error tracker.

// app/shelf/boom/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { slow } from "@/lib/slow";

async function Flaky(): Promise<never> {
  await connection();
  await slow(800, null);
  throw new Error("upstream failed");
}

export default function Page() {
  return (
    <main>
      <h1>Shell survives</h1>
      <Suspense fallback={<p>Loading flaky section...</p>}>
        <Flaky />
      </Suspense>
    </main>
  );
}
// app/shelf/boom/error.tsx
"use client";

export default function ErrorBoundary({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return <button onClick={reset}>Something went wrong. Retry</button>;
}

The measured response is 200, and the last chunk, about 800 ms after the shell, is the interesting one:

<script>self.__next_f.push([1,"6:E{\"digest\":\"3882397222\"}\n"])</script>
<script>/* $RX is defined here */ $RX("B:0","3882397222")</script></body></html>

The server did not render the error UI. It sent an error marker and a digest (a hash used to match the client error with the server log, so that the message itself is not leaked to the browser in production), and $RX flips the boundary into its client-render state. The error.js component, which is a Client Component, renders in the browser when the page hydrates; until then the user still sees the Suspense fallback. So an error inside a streamed boundary needs JavaScript to show its UI, and reset() retries the boundary by re-rendering it. The error file’s other properties (it does not catch errors in the layout of its own segment, global-error.js, catchError) are covered in layouts, templates and special files.

Bots, Crawlers and htmlLimitedBots

Search engines and link-preview bots differ in what they can do with a streamed page. Some run JavaScript and can read the metadata that Next.js streams into the <body> (streaming metadata, see the page on metadata and SEO). Others only read the static HTML and expect <title> and the Open Graph tags in the <head> of the first bytes. Next.js therefore looks at the User-Agent:

  • For HTML-limited bots (the default list includes Google’s ad and page-renderer crawlers, Bingbot, Twitterbot and Slackbot) it waits for generateMetadata to resolve before it sends the page, so that the metadata is in the head of the initial HTML.

  • For browsers and DOM-capable crawlers it streams the metadata alongside the content.

Because the decision is made on the request, you can watch it. The seo route has a slow metadata lookup (1 s) and a slow body (2 s):

// app/shelf/seo/[id]/page.tsx
import { Suspense } from "react";
import type { Metadata } from "next";
import { connection } from "next/server";
import { getBook } from "@/lib/books";
import { slow } from "@/lib/slow";

export async function generateMetadata({ params }: PageProps<"/shelf/seo/[id]">): Promise<Metadata> {
  const { id } = await params;
  await connection();
  await slow(1000, null); // slow metadata lookup
  const book = await getBook(id);
  return { title: book?.title ?? "Unknown" };
}

async function Slow() {
  await connection();
  await slow(2000, null);
  return <p>Slow body</p>;
}

export default function Page() {
  return (
    <main>
      <h1>SEO demo</h1>
      <Suspense fallback={<p>Loading body...</p>}><Slow /></Suspense>
    </main>
  );
}
node observe.mjs http://localhost:3404/shelf/seo/1                       # a browser-like client
node observe.mjs http://localhost:3404/shelf/seo/1 "Twitterbot/1.0"     # an HTML-limited bot
# browser-like client                    # Twitterbot
status 200, headers after +32ms          status 200, headers after +1049ms
chunk 0 (+33ms) 7379 bytes               chunk 0 (+1050ms) 7656 bytes   <- shell, with the title already in <head>
chunk 1 (+1029ms) 435 bytes              chunk 1 (+2045ms) 94 bytes
chunk 2 (+1029ms) 1280 bytes             chunk 2 (+2045ms) 919 bytes
chunk 3 (+2030ms) 94 bytes
chunk 4 (+2033ms) 90 bytes

The browser gets its first byte after 32 ms, with the book title streamed later in a hidden segment. The bot’s response does not start until the metadata has resolved (about 1 s), and then the shell carries the <title>; the slow body still streams after it, so in this experiment the cost for the bot is the metadata wait, not the whole render. Two consequences follow. A slow generateMetadata delays every bot request, so keep it fast and cached; and the crawler’s view of the page is the one with the real <head>, which is what the Rich Results Test shows.

htmlLimitedBots

The htmlLimitedBots option (introduced in 15.2.0) is a regular expression that decides which user agents get the blocking behaviour:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  htmlLimitedBots: /MySpecialBot|MyAnotherSpecialBot|SimpleCrawler/,
};

export default nextConfig;

Setting it replaces Next.js' default list rather than adding to it, which is advanced behaviour: with the value above, Twitterbot is no longer treated as HTML-limited. This was verified with the same two requests; with htmlLimitedBots: /MyCrawler/ a Twitterbot/1.0 request streamed like a browser (first byte after 80 ms), while a MyCrawler/2.0 request waited about a second for its metadata:

$ node observe.mjs http://localhost:3404/shelf/seo/1 "MyCrawler/2.0"
status 200, headers after +1025ms

To fully disable streaming metadata, so that every client receives metadata in the head, match everything:

const nextConfig: NextConfig = {
  htmlLimitedBots: /.*/,
};

That is the safest choice when you cannot be sure which crawler matters to you, and the price is a time to first byte that includes generateMetadata.

Cache Components and Bots

With Cache Components, visitors and DOM-capable crawlers receive the prerendered shell immediately and the dynamic content streams after it. HTML-limited bots skip the prerendered shell and render the page dynamically, so that metadata can be placed in the head; once metadata has resolved, the rest of the page can still stream. Watch for a shell that depends on inputs that exist only while prerendering (build-time data, values not reachable at request time). A person receives the shell without that code re-running, but a bot re-renders it, so a page that loads for people can fail for a crawler. Make sure every input the shell relies on is also available at request time.

What Can Break Streaming: Proxies, CDNs, Compression and Clients

Streaming is only as good as the weakest hop. The HTML may be produced progressively and still arrive as one block because something between the server and the user collects the chunks first.

Table 2. Layers that buffer, and what to do about them
Layer Problem What to do

Reverse proxy (nginx and similar)

Buffers upstream responses by default.

Send X-Accel-Buffering: no, or disable buffering in the proxy configuration for the Next.js location.

CDN

May buffer entire responses before forwarding them.

Check the provider’s documentation; some need configuration or a plan tier to pass chunked responses through.

Serverless platform

Not all environments stream. AWS Lambda needs response streaming mode enabled explicitly (it is not the default).

Use a platform with streaming support, or enable the mode. Vercel supports streaming natively.

Compression (gzip, Brotli)

The compressor can hold chunks until it has enough data, delaying the first visible chunk.

Make sure the compression layer flushes aggressively; disable compression while testing.

Browser

Safari and WebKit buffer a streaming response until 1024 bytes have arrived.

Only affects minimal demos and tiny Route Handler responses; real pages exceed 1 KB.

Command-line tools

curl buffers by default; -N helps, but it flushes by lines.

Use a script that reads the body stream when you need to see chunks.

Disabling Buffering Behind nginx

Next.js can add the header itself, which is the documented configuration for self-hosting behind nginx:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*{/}?",
        headers: [{ key: "X-Accel-Buffering", value: "no" }],
      },
    ];
  },
};

export default nextConfig;
$ curl -sI http://localhost:3404/shelf/dashboard | grep -i x-accel
X-Accel-Buffering: no

nginx honours this header per response, so no change in the nginx configuration is needed for the routes it covers. If you cannot send it, set proxy_buffering off; in the location block that proxies to Next.js.

Compression

next start compresses responses itself (compress: true is the default) and the observed response was Content-Encoding: gzip with Transfer-Encoding: chunked. The dashboard still streamed through it: requested with Accept-Encoding: gzip, the observer script saw the boundaries flush at +54 ms, +1041 ms and +2542 ms, as without compression. If you terminate compression in a proxy instead, verify it the same way: set the Accept-Encoding: identity header to rule compression out and compare. Disabling Next.js' compression (compress: false) is only appropriate when a reverse proxy compresses.

Platform Support

Table 3. Streaming support by deployment option
Deployment option Supported

Node.js server (next start)

Yes

Docker container

Yes

Static export (output: 'export')

No

Adapters

Platform-specific

A static export has no server to stream from, so loading.js and Suspense fallbacks have no streaming effect there. The introduction and the deployment page cover the options.

Streaming Data to Client Components with use()

A Server Component can start a fetch, not await it, and pass the pending promise to a Client Component as a prop. The promise can travel through as many layers as needed; only the component that reads it with React’s use API needs a <Suspense> boundary around it. The fallback is part of the shell, and when the promise resolves React streams the finished HTML.

// app/shelf/promise/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { slow } from "@/lib/slow";
import { StatsChart } from "./stats-chart";

async function getStats() {
  await connection();
  return slow(1200, { books: 3, reviews: 12 });
}

export default function Page() {
  const statsPromise = getStats(); // start now, do not await
  return (
    <main>
      <h1>Stats</h1>
      <Suspense fallback={<p>Loading chart...</p>}>
        <StatsChart dataPromise={statsPromise} />
      </Suspense>
    </main>
  );
}
// app/shelf/promise/stats-chart.tsx
"use client";

import { use } from "react";

type Stats = { books: number; reviews: number };

export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
  const stats = use(dataPromise);
  return <p id="stats">{stats.books} books, {stats.reviews} reviews</p>;
}
status 200, headers after +45ms
chunk 0 (+46ms) 7761 bytes       <- shell with "Loading chart..."
chunk 1 (+1237ms) 75 bytes       <- the resolved value, serialised in the payload
chunk 2 (+1238ms) 964 bytes      <- the finished HTML, swapped in

Rules that make this work:

  • Do not await the promise in the Server Component, that would move the wait out of the boundary and back into the shell. Starting it before the return is what lets the work overlap with the rest of the render.

  • The value must be serialisable, because it crosses the server-client boundary in the payload; a function, for example, cannot be part of it. See the page on Server and Client Components.

  • use can be called conditionally, unlike a Hook, but it must run during render; reading a promise created in a Client Component’s render creates a new promise on every render. Pass promises that the server created.

  • If several components need the same data, start it once and pass the promise through a context provider, so that any component of the subtree can call use() on it. The Server Component creates the promise, the provider (a Client Component) stores it, and each consumer sits under its own boundary:

// app/layout.tsx
import { getUser } from "@/lib/data";
import { UserProvider } from "./user-provider"; // a Client Component that stores the promise in context

export default function Layout({ children }: { children: React.ReactNode }) {
  const userPromise = getUser();
  return <UserProvider userPromise={userPromise}>{children}</UserProvider>;
}

For React’s own view of use and Suspense, see Code Splitting and Suspense and Server Rendering in the React reference; this page adds only the Next.js integration.

Streaming Outside React: Route Handlers

Suspense streams UI. Raw responses (Server-Sent Events, large files, generated exports) are streamed from a Route Handler with the Web Streams API:

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        controller.enqueue(encoder.encode(`Chunk ${i + 1}\n`));
        await new Promise((resolve) => setTimeout(resolve, 200));
      }
      controller.close();
    },
  });
  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" },
  });
}

The curl --no-buffer run in Seeing the Chunks Arrive shows the five chunks arriving 200 ms apart. To stream a large file without loading it in memory, return file.readableWebStream() from a FileHandle opened with node:fs/promises. The page on Route Handlers covers the rest of that API.

Streaming and Web Vitals

Streaming changes several of the metrics that Web Vitals measure.

Table 4. How streaming affects the Core Web Vitals
Metric Effect of streaming What to do

TTFB and FCP

TTFB drops from the slowest query to the time needed to render layouts and fallbacks, and the shell paints at once.

Keep the shell cheap and push data below boundaries.

LCP

An element inside a boundary cannot paint until the boundary’s content is swapped in, which also needs the inline script to run. React may also hold back a large boundary.

Keep the LCP element outside or above boundaries. Use preload on next/image for an LCP image so the fetch starts in the first chunk; it controls when the image is fetched, not when it paints.

CLS

The browser reflows when a fallback is replaced by content of another size.

Skeletons that match the final dimensions, or fixed or minimum-height containers.

INP

Each boundary is a hydration unit (selective hydration): React hydrates parts as they stream in and prioritises what the user interacts with, instead of one blocking pass.

Use several boundaries; the page on hydration goes deeper.

Resource discovery also improves: the first chunk carries the <link> and <script> tags, so CSS, JavaScript and fonts download during the server’s think time instead of after it. A rule of thumb from the guide: if a Suspense boundary is there, React might use it, even when you did not expect it (on a slow network or a busy CPU), so add boundaries that earn their place.

The Pages Router

Everything above is the App Router. The Pages Router has no loading.js, no per-component data fetching on the server and no Suspense-driven streaming of data: the props that getServerSideProps or getStaticProps return must be resolved before the page component renders, so a slow data source delays the first byte of the page. The loading state of a Pages Router application is built on the client, for example a loading component shown while a client-side fetch or a route change is in progress, and next/dynamic can code-split components. To move that slow part to a streamed boundary, migrate the route to the App Router, where the data call moves into an async Server Component under <Suspense>.

What Changed Recently

Table 5. Streaming-related changes by version
Version Change

15.0

Async request APIs (params, searchParams, cookies(), headers()), which is why reading them is now a Suspense matter.

15.2

htmlLimitedBots option introduced (blocking metadata for HTML-limited bots, streaming metadata for the rest).

16.0

experimental.ppr, dynamicIO and useCache were replaced by cacheComponents; proxy.ts replaces middleware.ts.

16.3

Instant Navigations (cacheComponents together with partialPrefetching) build on the static shell. Both flags are still opt-in, so the guide describes streaming for both models.

Next.js releases patch versions often; check the guide’s @doc-version header before relying on a detail.

Where the Books Are Outdated

Both requester-provided books are written for the Pages Router and predate Suspense-based streaming in Next.js. In them, a loading state is something the page builds by hand (a component rendered while client-side data is pending), and getServerSideProps blocks until the whole page’s data is ready. Today the same screen is an async Server Component under <Suspense> or a loading.js file, with the shell sent first. They also predate the HTTP caveats of this page, the htmlLimitedBots list and Cache Components, so read them for concepts only.

Common Pitfalls

  • Awaiting at the top. One await cookies() or await params in a layout or page makes everything below it dynamic and removes it from the shell. Push it down (Pushing Dynamic Access Down the Tree).

  • One giant loading.js. It is a valid boundary but the result is a full-page skeleton, not progressive loading.

  • A layout that reads request data. loading.js does not cover its own segment’s layout.

  • Expecting a 404 mid-stream. A notFound() below a boundary returns 200 plus noindex. Check existence before the first boundary or in Proxy.

  • Treating 200 as health. Errors inside a boundary are not 5xx; log them on the server.

  • A slow generateMetadata. Bots wait for it before the first byte.

  • Overriding htmlLimitedBots without the defaults. The option replaces the default list, so crawlers you did not list stop getting blocking metadata.

  • Buffering in the proxy, CDN or compressor. Observe the raw response through the real infrastructure, not only on localhost.

  • Skeletons that do not match the final layout. They shift the page (CLS).

  • The LCP element inside a boundary. It cannot paint before the swap.

  • Boundaries that do not suspend. A boundary around synchronous content costs nothing but adds nothing; place them where there is asynchronous work.

  • Testing with next dev. The dev server compiles on demand and behaves differently; use next build and next start to measure.

  • Using streaming with a static export. There is no server, so there is nothing to stream.

The pages on rendering fundamentals, server-side rendering, hydration, Partial Prerendering and Cache Components, metadata and SEO, and Route Handlers (in this section) continue from here.