Streaming and Suspense
The idea in one sentence
Section titled “The idea in one sentence”Streaming lets the server send the ready parts of a page — the static shell — immediately, then push the slower parts down the same connection as they finish rendering.
The static shell streams first
Section titled “The static shell streams first”Without streaming, the server holds the whole response until every await resolves, so the slowest piece of data sets the time to first byte. Streaming breaks the page into chunks: the shell (layout, headings, anything not waiting on data) leaves the server first, and slow regions follow as they resolve.
The browser paints the shell right away, so the user sees a real page while data is still loading in the background.
loading.tsx is an automatic Suspense boundary
Section titled “loading.tsx is an automatic Suspense boundary”Drop a loading.tsx beside a page.tsx and Next.js wraps that whole segment in a Suspense boundary for you. While the page awaits its data, the loading UI shows instantly.
export default function Loading() { return <p>Loading your feed…</p>;}You write no <Suspense> yourself here — the file convention is the boundary. The segment streams in once its data is ready.
Manual Suspense streams just one slow part
Section titled “Manual Suspense streams just one slow part”When only one region of the page is slow, wrap that region in <Suspense> so the rest of the page renders immediately and only the slow component streams in.
import { Suspense } from 'react';
async function SlowComments() { const comments: { id: number; text: string }[] = await fetch('https://api.example.com/comments', { cache: 'no-store', }).then((r) => r.json()); return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;}
export default function FeedPage() { return ( <section> <h1>Your feed</h1> <p>This heading and text ship in the shell, immediately.</p> <Suspense fallback={<p>Loading comments…</p>}> <SlowComments /> </Suspense> </section> );}The heading paints at once; SlowComments streams in and replaces its fallback when the fetch resolves.
loading.tsx and a manual <Suspense> solve the same problem at different granularity: loading.tsx covers an entire segment (every dynamic API and every fetch on that page waits behind one fallback), while a manual boundary lets you carve out just the slow region and keep the rest of the page — the parts that are already fast — out of the fallback entirely. Reach for loading.tsx for a whole route’s first load, and a manual <Suspense> when only one component on an otherwise-fast page is slow.
A Suspense boundary wrapping the whole page buys nothing
Section titled “A Suspense boundary wrapping the whole page buys nothing”Wrapping the entire page in one <Suspense> still lets the server flush a fast set of response headers, but if nothing meaningful sits outside that boundary, there’s no real shell to show — the visitor stares at the fallback for the same total time it would have taken without Suspense at all. Proven with a real server and a chunk-timing script (Accept-Encoding: identity disables compression so chunks aren’t buffered by that layer, matching the technique the Next.js docs use to verify streaming):
export const dynamic = 'force-dynamic';
async function slow() { await new Promise((r) => setTimeout(r, 1500)); return 'slow data';}
export default async function Page() { const data = await slow(); // no Suspense at all — the whole response blocks return ( <main> <h1>Shell heading</h1> <p>{data}</p> </main> );}export const dynamic = 'force-dynamic';import { Suspense } from 'react';
async function Slow() { await new Promise((r) => setTimeout(r, 1500)); return <p>slow data</p>;}
export default function Page() { return ( <Suspense fallback={<p>Loading…</p>}> <main> <h1>Shell heading</h1> <Slow /> </main> </Suspense> );}export const dynamic = 'force-dynamic';import { Suspense } from 'react';
async function Slow() { await new Promise((r) => setTimeout(r, 1500)); return <p>slow data</p>;}
export default function Page() { return ( <main> <h1>Shell heading</h1> <p>This ships immediately, in the shell.</p> <Suspense fallback={<p>Loading…</p>}> <Slow /> </Suspense> </main> );}Running all three against next start and reading the raw HTTP chunks as they arrive (timestamps relative to when the request was sent):
stream-none headers +1536ms chunk 0 +1540ms (single chunk, nothing before it)stream-wrap-all headers +32ms chunk 1 +1530ms chunk 2 +1531ms (fallback only, then everything at once)stream-granular headers +28ms chunk 1 +1528ms chunk 2 +1529ms (real shell content, then the slow part)stream-wrap-all technically gets a fast time-to-first-byte — the fallback itself streams at 32ms — but the visitor sees nothing but a spinner until 1531ms, the same total wall-clock time as stream-none’s single blocking response at 1540ms. stream-granular gets the same fast headers and shows real, useful content (the heading and intro text) during that entire waiting window. The win from a manual <Suspense> isn’t a faster total time — it’s real content ready with nothing spent waiting on it, instead of a boundary so wide that its fallback is all there is to show.
What breaks streaming in production
Section titled “What breaks streaming in production”Any layer between your server and the browser that buffers the whole response before forwarding it defeats streaming, even though the server generated it progressively:
- Reverse proxies — Nginx and similar proxies buffer responses by default. The Next.js docs’ fix is a response header, not an nginx directive: set
X-Accel-Buffering: noon the response. - CDNs — some buffer the entire response before forwarding; check your provider’s streaming support.
- Serverless platforms — not all support streaming by default; AWS Lambda requires response-streaming mode to be explicitly enabled.
- Compression — gzip/Brotli can buffer chunks internally to compress efficiently, adding latency before the first visible chunk.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { async headers() { return [ { source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }], }, ]; },};
export default nextConfig;Why streaming improves perceived performance
Section titled “Why streaming improves perceived performance”Streaming decouples “first paint” from “slowest data”. Two wins fall out of that:
- Better TTFB — the shell leaves the server without waiting on any data request, so the first byte arrives fast.
- Better perceived performance — the user reads a real layout while slow regions fill in, instead of staring at a blank screen until everything is ready — as long as there’s real content in that shell, not just a Suspense fallback wrapping everything.
flowchart LR A[Request] --> B[Server sends static shell now] B --> C[Browser paints shell, fast TTFB] D[Slow async component resolves] --> E[Server streams the chunk] E --> F[Suspense fallback swaps to real content]
Mental Model
Section titled “Mental Model”Think of the response as a delivery truck that can drop off packages one at a time instead of waiting until every package on the route is loaded. loading.tsx is “hold the whole truck at the depot until this one route is ready” — coarse, but automatic. A manual <Suspense> is “load the truck with everything that’s ready now, and send a second truck for the one package that’s still being packed” — the driver (the browser) starts moving immediately with real cargo. Wrapping the entire page in one <Suspense> is the depot trick again, just given a different name — the truck still doesn’t leave with anything useful until the last package arrives.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
loading.tsx | Zero-code Suspense boundary for a whole segment | Coarse — the entire segment’s fallback shows even if only one fetch is slow |
Manual <Suspense> around one component | Rest of the page renders immediately with real content | Requires identifying which component is actually slow |
<Suspense> around the entire page | None over not streaming at all, if nothing sits outside it | Same total wait as blocking, but looks like it should help |
X-Accel-Buffering: no | Restores real streaming behind Nginx-style reverse proxies | Must be set explicitly — the default silently reduces streaming to one big chunk |
Common Misconceptions
Section titled “Common Misconceptions”- Wrapping more of the page in
<Suspense>always helps. A boundary is only useful if there’s real, ready content outside it. Wrapping everything just delays the same content behind a fallback instead of showing it sooner. loading.tsxand a manual<Suspense>are interchangeable.loading.tsxcovers the whole segment at once; a manual boundary lets you keep parts of the page out of any fallback at all. Use the granular one whenever only part of a page is slow.- If the server streams, the browser always sees it stream. A reverse proxy, CDN, compression layer, or even a buffering HTTP client can turn a genuinely progressive server response into one delayed blob by the time it reaches code that reads it.
- Streaming makes slow data fast. It doesn’t shorten the slow request — it changes what the visitor sees while waiting for it, from a blank screen to a partially-complete, real page.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches streaming or Suspense, check for these before trusting it:
- Is a
<Suspense>boundary wrapped around literally everything on the page, with nothing meaningful outside it? That gets a fast TTFB for a fallback, not for real content — check whether anything genuinely fast was left inside the boundary that could be pulled outside it. - Is the slow
awaitactually inside the component that’s wrapped in<Suspense>— or is it awaited one level up, in the parent, before the<Suspense>-wrapped child is even rendered? An await above the boundary blocks the whole tree regardless of where the<Suspense>sits below it. - Does a
loading.tsxexist for a segment where only one component is actually slow, when a manual<Suspense>around just that component would let the rest of the page render immediately instead? - Does deployment guidance (a Dockerfile, an nginx config, a reverse-proxy setup) omit disabling response buffering? Streaming can work perfectly on
next startand still arrive as one blob in production behind an unconfigured proxy.
🐛 Spot the bug
import { Suspense } from 'react'
async function getFeed() {return fetch('https://api.example.com/feed', { cache: 'no-store' }).then((r) => r.json())}
async function Comments() {const comments = await fetch('https://api.example.com/comments', { cache: 'no-store' }).then((r) => r.json())return <ul>{comments.map((c: { id: number; text: string }) => <li key={c.id}>{c.text}</li>)}</ul>}
export default async function FeedPage() {const feed = await getFeed()return ( <section> <h1>Your feed</h1> <p>{feed.headline}</p> <Suspense fallback={<p>Loading comments…</p>}> <Comments /> </Suspense> </section>)}Show the answer
The <Suspense> around Comments looks correct in isolation, but FeedPage itself is an async function that awaits getFeed() before it returns any JSX — including the <Suspense> boundary. Nothing can stream until that outer await resolves, because React hasn’t even started rendering the tree with the boundary in it yet. The fix is to render the heading and shell synchronously and push the getFeed() call into its own child component, wrapped in its own <Suspense> — the same fix, applied one level higher, that keeps the fast parts of the page out of every slow await’s way.