Streaming & Suspense: the response as a stream
- Explain how a streamed App Router response is delivered: the shell and Suspense fallbacks first, each boundary's content later in the same response
- Place
loading.tsxor<Suspense>for a page, and predict whichawaitblocks the whole route and which blocks one section - Predict the status code when
notFound()orredirect()runs after streaming has started, and move the check before anything streams - Explain Partial Prerendering under Cache Components: the prerendered shell, dynamic holes, and the blocking-route error for uncached data outside
<Suspense> - Spot the layers that buffer a stream (nginx, compression, curl, the platform) and verify chunk timing with a stream reader
Read
The page arrives in pieces — one response, many chunks
FlagWithout streaming, a server builds the whole HTML document and only then sends it, so the page is as slow as its slowest query. With streaming the server sends the parts that are ready now and the rest later, in the same response, using chunked transfer encoding.
Here is that at the HTTP level, from a small node:http server that writes three pieces 800 ms
apart, read with fetch and a stream reader:
/plain: status 200, transfer-encoding=chunked, content-length=null
+ 31ms <p>shell</p>
+ 827ms <p>slow part</p>
+1628ms <p>end</p>
No Content-Length (the server doesn't know it yet), and the first bytes arrive in 31 ms although
the response takes 1.6 s. The browser starts painting from the first chunk.
The App Router does this for you, and <Suspense> decides where the cuts go:
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading revenue…</p>}>
<Revenue /> {/* async: ~200 ms */}
</Suspense>
<Suspense fallback={<p>Loading orders…</p>}>
<RecentOrders /> {/* async: ~1 s */}
</Suspense>
</div>
);
}
The first chunk carries the <h1> and both fallbacks: the static shell. When Revenue
resolves, React streams its HTML plus a tiny inline script that swaps it in for the fallback. A
second later the orders do the same. Each boundary resolves on its own: the slow one never holds
up the fast one, and none of them holds up the shell.
That's the whole mental model:
- everything outside any pending boundary → first chunk;
- each
<Suspense>boundary → its fallback first, its real content when its async work finishes; - the user sees the page fill in section by section, and can interact with what has hydrated.
Which await blocks the route — push it down to where the value is used
FlagAn await blocks everything rendered after it in the same component, and everything below
that component. So the higher an await sits, the more of the page waits for it.
// ❌ app/products/[id]/page.tsx — one slow call holds the whole page
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id); // 80 ms
const recommendations = await getRecommendations(id); // 2.5 s
return (
<>
<ProductDetails product={product} />
<Recommendations items={recommendations} />
</>
);
}
Nothing can stream until the 2.5-second call returns, because the JSX that would contain the
<Suspense> boundaries hasn't been produced yet. Move the slow await into the component that
needs it and give that component a boundary:
// ✅ the details render now; only the recommendations wait
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<>
<ProductDetails product={product} />
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</>
);
}
async function Recommendations({ productId }: { productId: string }) {
const items = await getRecommendations(productId); // suspends only this boundary
return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
That is how it plays out without Cache Components: the page still waits 80 ms for the product,
then streams. With cacheComponents: true the build enforces the same idea more strictly: the
remaining top-level work must be cacheable too (getProduct under use cache, params covered by
generateStaticParams), or it also moves behind a boundary. The next reading covers why.
The Streaming guide applies the same rule to request data in a layout. Don't await cookies()
at the top; start it and hand the promise to the component that reads it:
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies(); // started, not awaited
return (
<div>
<Nav>
<Suspense fallback={<p>Loading user…</p>}>
<UserMenu cookiePromise={cookieStore} />
</Suspense>
</Nav>
{children}
</div>
);
}
<Nav> and {children} stay in the shell; only UserMenu waits.
Sibling or nested? Sibling boundaries resolve independently, each in whatever order its data arrives. Nested boundaries reveal in order: the inner fallback can only appear once the outer content has rendered. Use siblings for unrelated panels and nesting for "details, then reviews below them".
A Client Component can wait too. Start the fetch in a Server Component without awaiting it,
pass the promise as a prop, and read it in the Client Component with React's use(promise) inside
a <Suspense>. The fallback ships in the shell; the data streams in when the promise resolves.
The status code goes out with the first byte
FlagAn HTTP response starts with its status line and headers. Once the first chunk of a streamed body has been sent, they're on the wire and can't be taken back. Node shows it plainly:
res.write("<p>shell</p>"); // status + headers are sent with this first chunk
await sleep(100);
res.statusCode = 404; // no effect: the client already has 200
res.setHeader("X-Late", "1"); // throws ERR_HTTP_HEADERS_SENT
/status: status 200, x-late=null
+ 2ms <p>shell</p>
+ 103ms <p>notFound() happened here; setHeader threw: ERR_HTTP_HEADERS_SENT</p>
Next.js lives under the same rule. Streaming starts when a Suspense fallback renders (including
one from loading.tsx) or a component suspends under a boundary. After that:
notFound()can't send a 404. Next streams the not-found UI and injects<meta name="robots" content="noindex">so search engines skip the URL, but the status stays200. Some crawlers report it as a "soft 404".redirect()can't send a 307. It becomes a client-side redirect: Next injects a<meta http-equiv="refresh">tag into the stream instead.- An error thrown mid-stream is caught by the nearest
error.tsx, which replaces that section; the rest of the page stays, and the status is still200.
The trap is how easily a page ends up streaming before its check:
app/posts/[slug]/
loading.tsx ← makes the whole page a Suspense boundary
page.tsx ← const post = await getPost(slug); if (!post) notFound();
loading.tsx wraps the page, so the moment getPost suspends, the fallback streams with a 200,
and a missing post becomes a soft 404 for every crawler and every uptime check.
When the status code matters (missing resources, auth redirects, analytics), decide it before
anything streams: a fast existence check at the top of the page with no boundary above it, or
earlier still in proxy.ts (rewrite to a not-found route, or return a 404 response) or a
next.config redirect. Both run before rendering, while the status line is still unwritten.
Quiz
A product page awaits getProduct() (80 ms) and then getRecommendations() (2.5 s) at the top of page.tsx, before returning both sections. How do you get the product on screen while recommendations load?
app/dashboard/layout.tsx does const user = await getUser((await cookies()).get("session")?.value). The folder also has loading.tsx. Without Cache Components, what happens when someone navigates to /dashboard?
Three panels resolve at 200 ms, 1 s and 3 s. They're wrapped in one <Suspense> boundary. When does the user see the 200 ms panel?
app/posts/[slug]/ has loading.tsx, and page.tsx does const post = await getPost(slug); if (!post) notFound();. What status does a request for a missing slug get?
With cacheComponents: true, a page has a static <header>, a <BlogPosts> component using use cache + cacheLife("hours"), and <Suspense fallback={<p>Loading…</p>}><UserPrefs /></Suspense> where UserPrefs reads cookies(). What's in the prerendered shell?
Under Cache Components, a Server Component renders Math.random() and is wrapped in <Suspense>. What happens at next build?
Streaming works on next start locally. Behind your nginx in production, each page arrives in one piece after the slowest boundary. First suspect?
Fill in the blank
Name the file that wraps this folder's page in a Suspense boundary.
Flagapp/dashboard/.tsx
Stream the orders panel on its own, with a placeholder until it's ready.
Flag< fallback={<p>Loading orders…</p>}> <RecentOrders /> </Suspense>
Read a promise started in a Server Component, inside a Client Component under <Suspense>.
"use client"; import { use } from "react"; export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) { const stats = (dataPromise); return <Chart data={stats} />; }
Under Cache Components, make this component produce a new ID per request instead of one frozen at build.
Flagimport { connection } from "next/server"; async function RequestId() { await (); return <p>Request {crypto.randomUUID()}</p>; }
Tell nginx in front of next start not to buffer streamed responses.
async headers() { return [{ source: "/:path*{/}?", headers: [{ key: "", value: "no" }] }]; }
Predict the output
// node:http, streaming by hand
res.setHeader("Content-Type", "text/html");
res.write("<p>shell</p>");
await sleep(100);
res.statusCode = 404;
try { res.setHeader("X-Late", "1"); } catch (e) { console.log(e.code); }
res.end("<p>not found</p>");// node:http + zlib, no flush between writes
res.setHeader("Content-Encoding", "gzip");
const gz = zlib.createGzip();
gz.pipe(res);
gz.write("<p>shell</p>");
await sleep(800);
gz.write("<p>late part</p>");
await sleep(800);
gz.end("<p>end</p>");
// a client timestamps each chunk it receivesexport default function Page() {
return (
<Suspense fallback={<FullPageSkeleton />}>
<Header /> {/* static */}
<Feed /> {/* awaits an uncached API, 2 s */}
</Suspense>
);
}