Streaming a page in chunks
How the App Router streams HTML in chunks over Suspense boundaries, and the parallel-fetch patterns that keep the first byte fast.
Picture the invoices dashboard from the last lesson, with three independent widgets stacked down the page.
A ProfileCard showing who’s signed in reads a single indexed row, call it ~10ms.
An analytics chart aggregates a few hundred rows, around 300ms.
An ActivityFeed joins three tables and sorts, around 800ms.
When the request comes in, what does the user see, and when?
With the classic server-rendering model, the server can’t send a single byte until it finishes rendering the page, and rendering means resolving every await on it.
So it waits for all three reads, and only once the slowest (the ~800ms feed) returns can it send anything.
The user stares at a blank tab for 800ms to see a profile card that was ready in 10.
The slowest query on the page sets the time-to-first-byte for everything on it.
That coupling is the problem this lesson solves.
You already know where to draw your <Suspense> boundaries, around the smallest piece of UI that loads as one concept.
Here you’ll see the machinery that makes those boundaries pay off, turning boundary placement into a performance decision as well as a UX one.
The server’s streaming sequence
Section titled “The server’s streaming sequence”Instead of building a complete HTML document and sending it at the end, the server opens the response and writes it in pieces, sending the parts that are ready while it works on the rest. That is streaming. Here is the sequence the server runs.
First, it renders the static shell , everything not behind a Suspense boundary: the <html> and <body>, the Header, the layout chrome.
Nothing holds it up, so it is fast.
Second, it writes the first chunk : the shell, plus each suspended boundary’s fallback in the exact spot that boundary lives.
The browser paints it immediately, so within milliseconds the user sees the full layout with skeletons standing in for the slow regions.
Third, as each boundary finishes resolving on the server, the server writes a follow-up chunk carrying two things: the boundary’s resolved HTML, and a tiny inline <script> that finds the fallback’s placeholder and swaps the real content into its slot.
The browser runs the script, the skeleton disappears, and only that region updates; the rest of the page never flickers.
Fourth, the connection stays open the whole time. The server holds the response until every boundary has settled and its chunk is written, then closes.
Three points are where intuition tends to go wrong. There is one response, not many: the browser made a single request and receives a single response, just in installments, not polling, not separate fetches per widget. Boundaries stream in whatever order they finish, not source order: if the chart resolves before the feed, its chunk goes down first regardless of JSX position. And the swap is a DOM patch, not a re-fetch: the follow-up chunk already contains the rendered HTML, so the browser never asks the server for anything again.
The following trace animates this for the dashboard. Drag the scrubber through the three phases and watch the order: the shell and skeletons appear together, then each feed streams into its own slot.
The shell and both feeds begin rendering on the server. The awaiting feeds hit their await and
suspend; the shell keeps going.
The shell plus both skeletons flush in the first chunk. The user sees the full layout instantly, with skeletons holding the slow regions.
Each feed streams into its slot the moment its own query resolves, in whatever order they finish, not source order.
Watch the stream phase again: the two feeds don’t arrive together.
Each chunk is written the instant its query comes back, independent of the other.
That independence is the entire payoff, and the next section is about not throwing it away.
What moves down that connection isn’t only HTML. Alongside the markup, the server flushes the RSC payload , the wire format you met with the server/client boundary, now being sent in chunks. You’ll spot it in DevTools later in this lesson.
Boundaries are the streaming configuration
Section titled “Boundaries are the streaming configuration”So how do you turn streaming on? You don’t. There is no export, no config flag, no opt-in.
A page.tsx doesn’t return a finished HTML document; it returns a stream.
As the renderer walks your tree, every time it hits a suspended boundary it writes the fallback and keeps going instead of waiting.
That is the default. The moment you wrap an async child in <Suspense>, that route streams.
A route with no suspended boundaries still streams in the trivial sense: nothing is held back, so it flushes one complete chunk.
A page’s streaming behavior is therefore decided entirely by where its boundaries are; there is no separate config to get right, because the boundaries are the config. This is why the previous lesson’s placement decision mattered: choosing where to draw a boundary was choosing what streams independently and what waits.
export default function DashboardPage() { return ( <main> <Header /> <Suspense fallback={<ProfileSkeleton />}> <ProfileCard /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <ActivityFeed /> </Suspense> </main> );}No export, no flag: these two boundaries are the entire streaming configuration.
Give each independent read its own boundary
Section titled “Give each independent read its own boundary”How you arrange your fetches decides whether streaming buys you anything.
The rule extends the previous lesson by one step: each unit of UX owns its own data fetch and its own Suspense boundary.
You write two boundaries, and inside each you put an async Server Component that starts its own query:
const ProfileCard = async () => { const profile = await getProfile(); return <ProfileWidget profile={profile} />;};
const ActivityFeed = async () => { const activity = await listActivity(); return <ActivityList items={activity} />;};Rendering the dashboard tree, the server reaches ProfileCard and starts getProfile(), then reaches ActivityFeed and starts listActivity().
Both queries are now in flight, running concurrently , because nothing made the server wait for the first before reaching the second.
Each boundary streams the instant its own query resolves: the profile at ~10ms, the feed at ~800ms, and the user never waits on the feed to see the profile.
Here is the trap, one of the most common latency bugs you will ship. It looks like a reasonable Server Component, but it is wrong:
const Dashboard = async () => { const chart = await getChartTotals(); const activity = await listActivity(); return ( <main> <ChartWidget chart={chart} /> <ActivityList items={activity} /> </main> );};Two serial awaits in one component: the second never starts until the first returns.
await pauses the function until its promise settles, so await listActivity() does not begin until getChartTotals() resolves.
The chart read finishes at 300ms, the activity read then runs from 300 to 1100ms, and because there is one render, nothing streams: the page ships only once both reads are done.
Yet neither read needs the other.
You paid 1100ms for work that, run together, takes 800.
So before you write a second await, ask whether it needs the result of the first. If not, the reads must not run serially.
Sequential awaits (one component)
latency = sum
The second await can’t start until the first returns, so the reads stack — and the single render ships only at the very end.
Parallel boundaries (two components)
latency = max
Both reads start together, so they overlap; first paint moves to the shell flush near zero, and each widget appears the moment its own bar ends.
Splitting into two components isn’t the only way to parallelize.
When the data is consumed together, Promise.all runs both reads in parallel inside one component, the subject of the next section.
Separate boundaries are the right move when each piece can render on its own.
const Dashboard = async () => { const chart = await getChartTotals(); const activity = await listActivity(); return ( <main> <ChartWidget chart={chart} /> <ActivityList items={activity} /> </main> );};One component, two serial awaits: total latency is the sum, and nothing streams. The second read can’t start until the first settles, so 300ms + 800ms = 1100ms, and the single render ships only once both are done.
const Dashboard = () => { return ( <main> <Suspense fallback={<ChartSkeleton />}> <ChartCard /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <ActivityFeed /> </Suspense> </main> );};Each child starts its own query and owns its own boundary: the reads run concurrently, the shell ships now, and each widget streams when it’s ready. ChartCard and ActivityFeed are async children that each await their own read, exactly as the trace played out.
When one component needs every read: Promise.all
Section titled “When one component needs every read: Promise.all”Separate boundaries are right when each piece renders on its own. But sometimes a single piece of UI needs all of several reads before it can render anything meaningful.
Consumed together. Picture a summary card that aggregates across the profile, the chart, and the feed into one header: “12 invoices, $48k, last activity 3 minutes ago.”
It can’t render half of itself, so there’s only one thing to reveal and three boundaries would be pointless.
Fetch all three reads inside one component, behind one boundary, with Promise.all:
const DashboardSummary = async () => { const [profile, chart, activity] = await Promise.all([ getProfile(), getChartTotals(), listActivity(), ]);
return <SummaryHeader profile={profile} chart={chart} activity={activity} />;};Promise.all starts all three reads at once and awaits them as a group, so they still run concurrently and you pay max(...), not the sum.
The difference from the previous section isn’t parallelism, since both shapes are parallel.
It’s the reveal granularity: here one fallback covers one combined unit, because there’s one thing to reveal.
Consumed adjacently. Picture three dashboard widgets side by side, each rendering on its own. Now you want three boundaries, one per widget, so the fast profile reveals without waiting on the slow feed.
So the decision comes down to one question: do these reads feed one rendered thing, or several?
One thing means Promise.all behind one boundary; several means several boundaries.
Both shapes run the reads in parallel.
What changes is how many fallbacks the user sees and when each region fills in.
Work through the walker below to drill the choice.
The reads still run concurrently: Promise.all starts them all at once and the component
awaits them as a group, so you pay max(...), not the sum.
One fallback covers the combined unit because there’s a single thing to reveal.
Each child starts its own read in its body and owns its own boundary, so the reads run concurrently and each region streams in independently. The fast piece reveals the moment its query resolves, without waiting on the slow one.
Above-boundary work blocks the first byte
Section titled “Above-boundary work blocks the first byte”Everything above every Suspense boundary runs to completion before any chunk flushes.
The server can’t send the shell until the shell is fully rendered, and the shell is everything not behind a boundary.
So a slow await in the root layout, or in the page body before the first boundary, isn’t streamed: it’s pure blank-screen time, paid up front on every request that renders that layout.
A 50ms global query in the root layout adds 50ms to TTFB for every page under it. The same query inside a Suspense-wrapped widget is invisible to TTFB: it streams in after the shell, while the user is already looking at the page.
That makes a layout a risky place to read data, since its cost lands on every child route, every time. Keep above-boundary work cheap, such as the auth check and the layout chrome, and push every slow read below a boundary where it can stream.
export default async function DashboardLayout({ children,}: { children: ReactNode;}) { const user = await requireUser(); const stats = await getDashboardStats();
return ( <Shell user={user} stats={stats}> {children} </Shell> );}requireUser() gates the whole subtree and must run before the shell, so keep it cheap.
getDashboardStats() is a slow read that belongs below a boundary; here it blocks the first byte for every route under this layout.
The trace from the start of the lesson makes this concrete: its server-render phase is the before-first-byte window, so anything awaiting there outside a boundary stalls the shell flush.
That’s why the trace refuses to render an awaiting node outside a <Suspense> and shows a “needs <Suspense>” error instead.
Confirm streaming in the Network panel
Section titled “Confirm streaming in the Network panel”Your render order shows what you meant to stream, not what reached the browser. To check, watch the response on the wire.
Open DevTools, go to the Network panel, and select the document request, the one for the page URL. The first chunk carries the shell and fallback HTML; later chunks carry each boundary’s resolved HTML and its swap script. The test is one question: did the first chunk arrive while later chunks were still in flight? If the whole body lands at once at the end, streaming didn’t happen.
What travels here is ordinary HTTP: chunked transfer encoding on HTTP/1.1, or its HTTP/2 equivalent. There’s nothing to set up; Vercel and a plain Node server both stream by default.
Here is the production gotcha that costs people an afternoon: something in the network path can buffer the whole response before forwarding it, collapsing streaming back into one-shot delivery even when your code is correct.
The usual culprits are a reverse proxy or CDN that buffers by default (Nginx, Traefik, a load balancer) and a compression layer that holds the body before flushing.
The fixes are proxy-side, like Nginx’s proxy_buffering off or the X-Accel-Buffering: no header, but that config is out of scope here.
So the goal is just to know where to look. When someone says “streaming works locally but not in production,” run the same sniff test: if the Network panel shows one large response landing at the end instead of incremental chunks, something in the path is buffering.
Page streaming versus real-time push
Section titled “Page streaming versus real-time push”Streaming the RSC payload is HTTP response streaming: one response written in chunks. It is not Server-Sent Events and not WebSockets. It flows server→browser once, for a single render, and the connection closes when the page is done. So it cannot push you anything after the page settles.
For real-time push, such as notifications, a chat message arriving, or presence dots going green, page streaming is the wrong tool. Use a dedicated channel instead: a hosted service like Pusher or Ably, or your own Server-Sent Events route handler.
Diagnose what defeats streaming
Section titled “Diagnose what defeats streaming”You can already write a <Suspense>.
The durable skill is spotting the shapes that quietly defeat streaming in code that looks completely fine.
Let’s do that on a real dashboard.
The file below renders a summary header and an activity feed. It compiles, it runs, and it’s slower than it should be. Review it like a teammate’s PR: click the lines where streaming is defeated and leave a comment on the problem and the fix.
This dashboard works but wastes time. Click the lines where streaming is defeated and say why — and how you'd fix it. Two defects are hiding here, and they don't have the same fix. Click any line to leave a review comment, then press Submit review.
const Summary = async () => { const profile = await getProfile(); const totals = await getChartTotals(); return <SummaryHeader profile={profile} totals={totals} />;};
export default function DashboardPage() { return ( <main> <Header /> <Suspense fallback={<DashboardSkeleton />}> <Summary /> <ActivityFeed /> </Suspense> </main> );}getChartTotals() doesn’t even start until getProfile() resolves, because await pauses the function until its promise settles. Neither read depends on the other, so you’re paying profile + totals for work that, run together, costs max(...).
Summary genuinely aggregates both reads into one view, so the fix is not two boundaries — it’s one concurrent fetch:
const [profile, totals] = await Promise.all([getProfile(), getChartTotals()]);Both reads start at once, and the single summary still reveals as one unit.
One boundary means one fallback for the whole region, so nothing reveals until the slowest child resolves — the fast Summary is held hostage by the slow ActivityFeed. That’s the opposite of progressive reveal.
Give each widget its own boundary so each streams in on its own schedule:
<Suspense fallback={<SummarySkeleton />}> <Summary /></Suspense><Suspense fallback={<ActivitySkeleton />}> <ActivityFeed /></Suspense>Now the summary appears the moment its reads finish, without waiting on the feed.
Both shapes defeat streaming, but they have different right answers, and you tell them apart by how the data is consumed — not by pattern-matching one fix onto both. The serial awaits serialize two independent reads, but because Summary consumes them together, the fix is Promise.all, not separate boundaries. The single top-level <Suspense> wraps two adjacently-consumed widgets, so its fix is the reverse: one boundary per widget. Serial awaits on independent reads, and one boundary doing the job of many — those are the two latency smells to hunt for in any page that looks fine but ships slow.
Check your understanding
Section titled “Check your understanding”Two quick checks on the points that are easiest to get wrong.
A streamed dashboard page has a fast ProfileCard and a slow ActivityFeed, each wrapped in its own <Suspense>. What lands in the browser in the very first chunk of the response?
ActivityFeed query has resolved.ProfileCard, since it’s fastest — the shell and the feed both stream in afterwards.ProfileCard and ActivityFeed each patch into their slot in a later chunk when their own query resolves. Waiting for all the data before sending anything is the classic-SSR model streaming replaces, and the shell never streams after its boundaries — it’s always first.Which of these is what RSC page streaming actually is?
One more round, on three points that are easy to half-remember.
Each claim is about how streaming is configured and where it can quietly fail. Mark each statement True or False.
You have to add an export to a page.tsx to turn streaming on.
<Suspense>, and a route with no boundaries trivially “streams” one chunk. Where you draw the boundaries is the entire streaming configuration.A slow await in the root layout is effectively free, since it streams in along with everything else.
await in the layout is pure blank-screen time — paid up front, on every route under that layout. Only reads below a boundary stream. Keep above-boundary work cheap (the auth check, the chrome) and push slow reads under a boundary.A reverse proxy or CDN that buffers the whole response can silently break streaming in production even when your code is correct.
Reveal card-by-card review
External resources
Section titled “External resources”The official App Router reference for how the shell flushes and Suspense boundaries stream — including TTFB, the network panel, and proxy buffering.
React's own account of how a Suspense boundary reveals its content during streamed server rendering.
Hands-on chapter that builds streaming into a real dashboard, boundary by boundary, in the official interactive course.