Skip to content
Chapter 31Lesson 2

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.

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.

Streaming the invoices dashboard

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.

app/dashboard/page.tsx
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:

app/dashboard/widgets.tsx
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:

app/dashboard/page.tsx
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

chart read 300ms
activity read 800ms
first byte — 1100ms
03008001100

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

shell + skeletons flush — ~0ms
chart read 300ms
chart streams in
activity read 800ms
activity streams in
03008001100

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.

Same reads, same data; only the shape of the code changes.

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.

app/dashboard/page.tsx
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.

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:

app/dashboard/summary.tsx
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.

One boundary or several?

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.

app/dashboard/layout.tsx
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.

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.

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.

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.

app/dashboard/page.tsx
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>
);
}

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?

The page shell, with a skeleton sitting in each widget’s slot — both real widgets arrive in later chunks.
An empty response: the browser sees nothing until the slow ActivityFeed query has resolved.
The finished page, profile and activity feed already rendered with their data.
Just the ProfileCard, since it’s fastest — the shell and the feed both stream in afterwards.

Which of these is what RSC page streaming actually is?

One HTTP response that the server writes in chunks for a single render, then closes once every boundary has settled.
A WebSocket the server keeps open so it can push fresh UI down to the page whenever the underlying data changes.
A Server-Sent Events feed that goes on delivering new content to the page long after it has finished loading.
The browser re-requesting the server on a timer, once per Suspense boundary, until each one comes back resolved.

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.

There is no opt-in export and no config flag. The renderer streams by default — a route streams the moment an async child sits inside a <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.

The opposite. Everything above every boundary must finish rendering before the first chunk can flush, so an 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.

Streaming is ordinary chunked HTTP, so anything in the path that holds the body until it is complete — a proxy buffering by default, or a compression layer that waits for more output — collapses it back into one-shot delivery. The boundaries are right and the user still waits for everything. The sniff test is the same: if the Network panel shows one large response landing at the end instead of incremental chunks, something is buffering.