Streaming and Suspense
AdvancedSend the shell immediately and let slow parts arrive as they resolve. One slow query stops holding the entire page hostage.
Overview
Server rendering has one structural weakness: the response cannot start until everything on the page is ready, so the slowest query decides the time to first byte. Streaming removes that. Next sends the HTML it already has, leaves a placeholder where a Suspense boundary sits, and pushes the real content down the same connection when it resolves. The user sees structure in a hundred milliseconds and the slow panel fills in a second later — the same total time, a completely different experience.
Boundaries Decide the Experience
Where you place Suspense is the design decision.
// No boundary — the whole page waits for the slowest query
export default async function Page() {
const [problem, stats, comments] = await Promise.all([...]) // 900ms
return <>…</> // nothing until then
}
// Boundaries — the shell is instant, each part streams
export default async function Page({ params }) {
const problem = await getProblem(params.slug) // fast, 40ms
return (
<>
<ProblemHeader problem={problem} /> {/* immediate */}
<Suspense fallback={<StatsSkeleton />}>
<Stats slug={params.slug} /> {/* 300ms, streams */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={params.slug} /> {/* 900ms, streams */}
</Suspense>
</>
)
}
// The awaited component must be INSIDE the boundary. This does not
// stream — the await happens before any JSX is returned:
const comments = await getComments()
return <Suspense fallback={...}><List items={comments} /></Suspense>
// loading.tsx is the same mechanism at segment level: it wraps the
// whole page in one boundary.Passing Promises Down
Start every fetch at once, await them in separate boundaries.
// Kick both off immediately, and let each resolve in its own place
export default function Page({ params }) {
const statsPromise = getStats(params.slug) // no await
const commentsPromise = getComments(params.slug) // no await
return (
<>
<Suspense fallback={<StatsSkeleton />}>
<Stats promise={statsPromise} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments promise={commentsPromise} />
</Suspense>
</>
)
}
async function Stats({ promise }) {
const stats = await promise
return <StatsPanel {...stats} />
}
// Both requests are in flight from the first line — no waterfall —
// and each renders as soon as its own data lands.
// React 19 adds use(), which unwraps a promise in a CLIENT component:
'use client'
import { use } from 'react'
function Comments({ promise }) {
const comments = use(promise) // suspends until resolved
return <List items={comments} />
}
// This is the promised follow-up to the React track: it lets a server
// component start a fetch and a client component consume it.What Streaming Costs
The constraints, and where it does not work.
// 1. Status codes and headers are already sent. Once streaming has
// begun you cannot return a 404 or set a cookie — so anything that
// decides the response must happen BEFORE the first boundary.
// An auth check belongs above the stream, not inside it.
// 2. Some hosts and proxies buffer responses, which silently defeats
// streaming. Verify on the real platform, not just locally.
// 3. Too many boundaries look like a broken page — five skeletons
// popping in at different times is worse than one honest wait.
// Group related content into one boundary.
// 4. A skeleton must match the final layout or you have traded a
// wait for a layout shift, which is worse.
// Where it pays: a dashboard with independent panels, a product page
// where reviews are slow, any page with one slow third-party call.
// Where it does not: a page that is fast anyway, or one where the
// content only makes sense complete.
// Measure it honestly — TTFB should drop sharply while the total
// load time stays about the same. That trade is the entire point.Key Points to Remember
- 1Streaming sends the shell immediately and pushes slow content down the same response as it resolves
- 2The await must be inside the Suspense boundary, or the page waits before rendering anything
- 3Start fetches without awaiting and pass the promises down to avoid a waterfall between boundaries
- 4use() lets a client component consume a promise started on the server
- 5Once streaming starts the status and headers are sent, so auth checks and redirects must happen before the first boundary
Interview Questions
Sign in to ask AriaWhat problem does streaming solve, and what does it not improve?
Why does awaiting data before returning a Suspense boundary defeat streaming?
Why must an authentication check happen before the first Suspense boundary?
Ask Aria about Streaming and Suspense
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.