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 thersc: 1header) 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.
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.
loading.js |
<Suspense> |
|
|---|---|---|
Scope |
The whole page and everything below it in the segment |
Any component |
Setup |
Drop a file next to |
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.jsdoes not wrap the layout. It wrapspage.js,not-found.jsand nested layouts, but not thelayout.js,template.jsorerror.jsof the same segment. If a layout awaits uncached or runtime data (cookies(),headers(), an uncachedfetch), 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.jsfallback 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.jshigh in the tree is a valid boundary, so the framework stops there, but the result is a full-page skeleton instead of granular streaming.
Source: loading.js, Streaming: Page-level streaming with loading.js, Streaming: When to use loading.js vs Suspense.
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.
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.
Source: Streaming: Error handling mid-stream, error.js.
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
generateMetadatato 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.
| Layer | Problem | What to do |
|---|---|---|
Reverse proxy (nginx and similar) |
Buffers upstream responses by default. |
Send |
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 |
|
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
| Deployment option | Supported |
|---|---|
Node.js server ( |
Yes |
Docker container |
Yes |
Static 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
awaitthe promise in the Server Component, that would move the wait out of the boundary and back into the shell. Starting it before thereturnis 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.
-
usecan 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.
| 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 |
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
| Version | Change |
|---|---|
15.0 |
Async request APIs ( |
15.2 |
|
16.0 |
|
16.3 |
Instant Navigations ( |
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()orawait paramsin 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.jsdoes not cover its own segment’s layout. -
Expecting a 404 mid-stream. A
notFound()below a boundary returns200plusnoindex. Check existence before the first boundary or in Proxy. -
Treating
200as health. Errors inside a boundary are not5xx; log them on the server. -
A slow
generateMetadata. Bots wait for it before the first byte. -
Overriding
htmlLimitedBotswithout 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; usenext buildandnext startto measure. -
Using streaming with a static export. There is no server, so there is nothing to stream.
Related Material on This Site
-
Introduction and Architecture: the rendering spectrum and the request flow.
-
Layouts, Templates and Special Files: where
loading.jsanderror.jssit. -
Linking and Navigation: prefetching of
loading.jsfallbacks and client navigation. -
Redirects, Rewrites and Proxy: rejecting requests before streaming starts.
-
Routing Fundamentals: the segment tree that boundaries attach to.
-
React: Code Splitting and Suspense and React: Server Rendering: the React primitives underneath.
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.