cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

Streaming server rendering

The server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.

The problem: one slow call holds the whole page hostage

Picture a product page on a shop. To render it, the server needs four things: the product itself (20 ms from a cache), the price for this user (40 ms), the stock level (60 ms), and the reviews (900 ms on a bad day, because the reviews service is slow). With classic server rendering, which the lesson Server-side rendering covered, the server waits for all four before it sends a single byte of HTML.

So the user stares at a blank tab for about a second. The browser can't start downloading the CSS, the fonts, or the hero image, because it hasn't seen the head yet. The product name and photo were ready after 20 ms, but they sit in server memory, waiting for reviews that most users never scroll down to read.

Two timelines for the same product page on a 0 to 1.6 second axis. Classic SSR: the server waits about 950 ms for all data, including 900 ms reviews, then sends the whole HTML, and the page paints at about 1.3 seconds. Streaming SSR: the shell leaves at about 120 ms and paints at about 0.5 seconds with a skeleton for the reviews, and the reviews chunk arrives at about 0.9 seconds.Classicwaiting on all datapaint 1.3 sStreamingskeleton showingreviews 0.9 sshell sent at 120 ms, paints at 0.5 s00.4 s0.8 s1.2 s1.6 s
Same data, same total time. Classic SSR shows nothing until the slowest call is done; streaming shows the useful part of the page early and fills in the slow part later.

All or nothing, twice

Classic server rendering is all or nothing in two places. On the server, nothing is sent until everything is fetched and rendered. In the browser, nothing is interactive until everything is hydrated: hydration (attaching event handlers and state to the server HTML) runs as one pass over the whole page, after the JavaScript for the whole page has downloaded.

  • Server side: the slowest data call sets the time to first byte (TTFB, the time until the first byte of the response reaches the browser) for everyone.
  • Browser side: the biggest component sets the time until the Buy button works, even if the Buy button itself is tiny.
  • Both waits add up. A 900 ms reviews call plus a 400 ms hydration pass means the user can click Buy only after about 1.5 seconds, even on a fast phone.

The usual workaround was to drop the slow part from the server render and fetch it in the browser after load. That works, but now the reviews arrive through a second round trip, after the JavaScript runs, and you write two data paths for one page. Streaming gives you a cleaner answer: send what is ready now, and send the rest on the same response when it's ready.

What streaming changes

Streaming server rendering sends the HTML in pieces. The first piece holds the head and the page shell with the fast content, plus placeholders (such as a skeleton) where slow parts will go. The connection stays open. When the reviews are ready, the server sends their HTML as another piece, with a tiny inline script that swaps it into the placeholder.

On the browser side, selective hydration splits the single hydration pass into many small ones. Each streamed piece hydrates on its own, as soon as its code and HTML are both there, and the piece the user is clicking on hydrates first. The Buy button doesn't wait for the reviews.

Think first

Why not just render the shell on the server and fetch the reviews in the browser with useEffect?

The mental model: a shell with holes, filled on one open line

Keep one picture in your head: the page is a shell with holes. The shell is everything that is ready quickly: the head, the navigation, the product title and photo, the price. Each hole is a part that waits on slow data: the reviews, the recommendations. The server sends the shell at once, with a placeholder in each hole, and then fills the holes one by one over the same open response.

A wireframe of a product page. The shell holds the navigation, the product photo, the name Trail Runner 3, the price of 129 dollars, and a Buy button; it is sent in the first chunk at about 120 ms. Below are two dashed holes: reviews, showing a skeleton until 900 ms, and also bought, showing a skeleton until 600 ms. Each hole is filled by its own later chunk.shop.example/p/42navigationTrail Runner 3$129Buyhole: reviewsskeleton until 900 mshole: also boughtskeleton until 600 msShell: first chunk,sent at about 120 msHoles: each filledby a later chunk,in any order
The shell is everything that is ready fast and answers the user's question. The dashed holes are Suspense boundaries: each shows a skeleton first and is filled by a later chunk, in whatever order the data is ready.

A restaurant works the same way. It doesn't hold your bread and salad until the slow-roasted main is done. It sets the table, brings each course when it's ready, and you start eating in minutes. The place settings are the placeholders: they show where each dish will go, so nothing on the table has to move when it arrives.

Three ideas that fit together

Streaming server rendering is really three separate ideas. They are often mixed up in interviews, so keep them apart:

  1. Sending HTML in chunks. HTTP lets a server send a body bit by bit and keep the connection open. Browsers parse and paint HTML as it arrives. This part is plain HTTP and has worked since the 1990s.
  2. Out-of-order streaming. The server sends each hole's content as soon as its data is ready, in whatever order that happens, and a tiny inline script moves it into the right hole. Without this, a slow part near the top would block everything below it.
  3. Selective hydration. In the browser, each hole hydrates on its own, as soon as its HTML and its JavaScript are both there. If the user clicks a part that isn't hydrated yet, that part jumps the queue.

In React, one component ties all three together: Suspense. A Suspense boundary marks a hole. On the server it says where the stream may send a fallback and come back later; in the browser it says where hydration may be split. You draw the boundaries once and get both benefits.

What streaming does not change

Streaming doesn't make any data call faster. The reviews still take 900 ms, and the last byte of the page still arrives after about 900 ms plus rendering time. What changes is what the user sees during that time: the useful parts of the page, instead of a white tab.

It also doesn't remove the cost of hydration. The browser still downloads and runs the same JavaScript for the same components. Selective hydration only splits that work into smaller tasks and puts the right ones first. If you want to send less JavaScript, that is a different tool: see the lessons Islands and partial hydration and React Server Components, which build on streaming.

Check your understanding · Exercise 1

A news article page needs: the article body (30 ms from a cache), the author bio (50 ms), the comments (700 ms), and personalised "read next" links (1,100 ms). What should be in the shell? Pick one.

How HTML travels in pieces: chunks, frames, and flushing

Streaming needs nothing new from HTTP. A response doesn't have to declare its size up front, and a server can write the body in as many pieces as it likes. What differs between HTTP versions is how those pieces are framed on the wire.

HTTP/1.1: chunked transfer encoding

Over HTTP/1.1, a response normally has a Content-Length header so the browser knows where the body ends. A streamed page can't know its length in advance, so the server sends Transfer-Encoding: chunked instead. Each chunk is its size in hexadecimal, a line break, the bytes, and another line break. A chunk of size zero marks the end.

http
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked

1f4
<!doctype html><html><head><link rel="stylesheet" href="/app.css">...
...<main><h1>Trail Runner 3</h1>...<template id="B:0"></template>...

9c
<div hidden id="S:0"><section>4.6 stars...</section></div>
<script>$RC("B:0","S:0")</script>

0

Two chunks of 500 bytes (1f4 in hex) and 156 bytes (9c), then the zero-size chunk that ends the body. The browser never sees the size lines; it only sees the HTML.

HTTP/2 and HTTP/3: DATA frames

HTTP/2 doesn't use chunked encoding at all. The specification forbids the Transfer-Encoding header, because every message is already split into frames. The headers travel in a HEADERS frame, the body in one or more DATA frames on the same stream, and the last frame carries an END_STREAM flag. HTTP/3 works the same way over QUIC.

So a streamed page over HTTP/2 is simply a stream whose DATA frames arrive with gaps between them. You don't set anything: your server code writes bytes, and the HTTP layer frames them. A common setup is an HTTP/1.1 app server behind a proxy or CDN that speaks HTTP/2 to the browser: the proxy drops the Transfer-Encoding header and passes each chunk on as DATA frames.

A sequence diagram with four lifelines: browser, CDN, server, and data APIs. The browser sends GET /p/42 to the server. The server asks the APIs for product, rail, and reviews at once. The product answers at 60 ms, and at 120 ms the server sends chunk 1, the head and the shell. The browser then fetches CSS, font, and hero image from the CDN while the server waits. The rail answers at 600 ms and the server sends chunk 2 with the rail and a swap script. The reviews answer at 900 ms and the server sends chunk 3 with the reviews and ends the response.BrowserCDNServerAPIsGET /p/42all 3 at onceproduct 60 mschunk 1: shell120 msCSS, font, herorail 600 mschunk 2: rail610 msreviews 900 mschunk 3: reviews910 ms
One request, one response, three chunks. Because the head is in chunk 1, the CSS, font, and hero image download while the server is still waiting on the rail and the reviews.

The browser parses as bytes arrive

Browsers have always parsed HTML incrementally. The parser builds the DOM from each chunk as it lands, and the preload scanner, a fast second parser that looks ahead for URLs, starts downloads for stylesheets, scripts, fonts, and images it finds. The browser can paint the content it has as soon as the stylesheets in the head have loaded, without waiting for the end of the document.

That is why flushing the head early is the first win of streaming, even before any Suspense. If the very first chunk contains the head, with its link rel="stylesheet", font preloads, and the preload for the hero image, those downloads start while the server is still fetching data. By the time the shell's HTML arrives, the CSS is often already there.

  • Put render-blocking CSS and link rel="preload" for fonts and the hero image in the head, so they are in the first chunk.
  • Load the client bundle without blocking the parser. React adds async to the scripts you pass as bootstrapScripts, so later chunks keep being parsed while the bundle downloads.
  • Don't put a synchronous script in the shell: it stops the parser until it has downloaded and run, and later chunks wait behind it.

Check your understanding · Exercise 2

Your page is served over HTTP/2. A teammate says streaming can't work because the response has no Transfer-Encoding: chunked header. What do you say? Pick one.

Suspense boundaries: where the stream may pause

Chunks are the transport. Something still has to decide what goes in which chunk. In React that is the Suspense boundary: a component with a fallback that wraps a part of the tree. When a component inside it is waiting for data, the server renders the fallback in its place, carries on with the rest of the page, and comes back to the boundary when the data is ready.

tsx
export default function ProductPage({ id }: { id: string }) {
  return (
    <Layout>
      <ProductHero id={id} />
      <BuyBox id={id} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} />
      </Suspense>
      <Suspense fallback={<RailSkeleton />}>
        <AlsoBought id={id} />
      </Suspense>
    </Layout>
  );
}
Everything outside a boundary (layout, hero, buy box) is the shell. Each boundary is a hole that can be filled later.

The shell is everything outside a boundary

React calls the part of the page outside every Suspense boundary the shell. The shell must finish rendering before the first chunk can go out, because there is no fallback to show in its place. So any data the shell needs sets the TTFB. If ProductHero waits 300 ms for its data, the first chunk waits 300 ms too.

Boundaries nest. A boundary inside another boundary can't appear before its parent: the browser first gets the parent's content (with the inner fallback inside it), then the inner content. Nesting is how you build a sensible reveal order, such as the review summary first and the review list a moment later.

A component tree. Page has four children: ProductHero and BuyBox, which are in the shell, and two dashed boundaries: Suspense A for reviews at 900 ms and Suspense B for the rail at 600 ms. Suspense A contains Summary and a nested Suspense C for the review list at 1.2 seconds. Suspense B contains AlsoBought. The order is: shell first, then B, then A with C's skeleton inside, then C.PageProductHeroBuyBoxSuspense Areviews 900 msSuspense Brail 600 msSummarySuspense Clist 1.2 sAlsoBoughtReviewListshell: first chunkOrder: shell, B, then A(with C's skeleton), then C
Solid boxes are the shell and go out in the first chunk. Each dashed box is a boundary that can arrive later. A nested boundary like C can only appear after its parent A, so nesting sets a sensible reveal order.

The server APIs: renderToPipeableStream and renderToReadableStream

React has two streaming server APIs. renderToPipeableStream is for Node.js streams. renderToReadableStream returns a web ReadableStream, for runtimes such as Deno, Bun, Cloudflare Workers, and other edge platforms. Both stream the same HTML; they differ in how they report progress.

tsx
import { renderToPipeableStream } from "react-dom/server";

app.get("/p/:id", (req, res) => {
  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      res.statusCode = 200;
      res.setHeader("Content-Type", "text/html; charset=utf-8");
      pipe(res); // first chunk leaves now
    },
    onShellError() {
      res.statusCode = 500;
      res.send("<h1>Something went wrong</h1>");
    },
    onError(error) {
      console.error(error); // also fires for errors inside boundaries
    },
  });
  setTimeout(() => abort(), 10_000); // give up on slow boundaries
});
onShellReady is the moment to set the status and headers and start piping. After that, they can't change.
Callback or fieldWhen it firesWhat you do there
onShellReadyThe shell has rendered; boundaries may still be pendingSet status and headers, call pipe(res)
onShellErrorThe shell itself threw, so nothing was sentSend a plain error page with status 500
onAllReadyEvery boundary has renderedPipe here instead for crawlers or static generation
onErrorAny error, in the shell or in a boundaryLog it; a boundary error falls back to client rendering
abort()You call it after a timeoutPending boundaries keep their fallbacks and render in the browser
stream.allReadyA promise on the renderToReadableStream resultThe web-streams version of onAllReady

On the client, hydrateRoot(document, <App />) picks up the server HTML. You rarely write this code yourself: Next.js, React Router, and other frameworks call these APIs for you. But the callbacks explain behaviour you will see, such as why a 404 status is impossible after the shell is sent.

Decision How many Suspense boundaries should a page have?

One boundary around the whole page

Wrap the page body in a single Suspense with a page skeleton as the fallback.

Pros

  • Simple; the head and layout still flush early.
  • One reveal, so nothing pops in piece by piece.

Cons

  • The page body waits for its slowest data, like classic server rendering.
  • One big hydration unit.

One boundary per slow, secondary section

Keep the main content in the shell.

Wrap each slow section (reviews, recommendations, comments) in its own boundary.

Pros

  • The main content paints in the first chunk.
  • Slow sections don't block each other.
  • Hydration splits along the same lines.

Cons

  • Needs a skeleton per section, sized to avoid layout shift.
  • You must know which data is slow.

A boundary around every component that fetches

Wrap each small data-fetching component, down to badges and counters.

Pros

  • Each piece shows the moment it can.

Cons

  • The page flickers as 20 pieces pop in.
  • Many skeletons, many layout shifts.
  • More swap scripts and markup.

What we pick: Start with one boundary per slow, secondary section, and keep the main content and the likely LCP element in the shell. Merge boundaries that usually resolve within about 100 ms of each other: two parts arriving together feel calmer than two pops.

Out-of-order streaming: filling holes in any order

HTML is a stream of text from top to bottom. The simplest kind of streaming, called in-order streaming, sends the page in document order and flushes whenever it can. That helps the head, but a slow part in the middle still blocks everything below it: the footer can't be sent before the reviews, because the footer comes after them in the text.

Out-of-order streaming removes that limit. The server sends the shell with a marked placeholder for each hole, and then sends each hole's content at the end of the document, as soon as it's ready, in whatever order the data arrives. A tiny inline script moves each piece into its hole. eBay's Marko framework did this years before React; React 18 made it mainstream.

What React actually sends

Here is a simplified view of the two chunks for the product page. React marks a pending boundary with HTML comments and an empty template that has an id. Later, it sends the finished content inside a hidden div with a matching id, followed by a call to a small helper.

html
<!-- chunk 1 (about 120 ms): the shell -->
<main>
  <h1>Trail Runner 3</h1>
  <p>$129</p>
  <!--$?--><template id="B:0"></template>
  <div class="reviews-skeleton"></div><!--/$-->
</main>

<!-- chunk 2 (about 900 ms): appended at the end of body -->
<div hidden id="S:0">
  <section><h2>Reviews</h2><p>4.6 stars from 1,280 reviews</p></section>
</div>
<script>$RC("B:0", "S:0")</script>
$RC finds the placeholder B:0, removes the fallback after it, moves the children of S:0 in, and marks the boundary as complete.
  • <!--$?--> means the boundary is still pending. After the swap it becomes <!--$-->, which tells hydration the content is real.
  • The template element is an invisible anchor: it renders nothing, but $RC can find it by id.
  • The content arrives in a div with the hidden attribute, so it never flashes at the bottom of the page before it's moved.
  • The helper function is defined inline once, in the first chunk that needs it, so later chunks only carry the short call.

Step through it below. Notice that the recommendations rail finishes before the reviews, so its chunk arrives first and fills its hole first, even though it sits lower on the page.

Watch a page stream inStep 1 of 7: Request sent

A step-through of a streamed product page. On the left, the chunks received so far: head, shell with two placeholders, the rail as a hidden div with a swap script, the reviews the same way, and the end of the response. On the right, the page on screen: blank, then the shell with two skeletons, then the rail filled in, then the reviews filled in.bytes receivedwaiting for bytest = 0 msshop.example/p/42blank
The browser has sent GET /p/42. The server asks for the product, the rail, and the reviews at once. Nothing has arrived yet, so the tab is blank.

Request sent: The browser has sent GET /p/42. The server asks for the product, the rail, and the reviews at once. Nothing has arrived yet, so the tab is blank.

The life of one boundary

Each boundary moves through a small set of states. On the server it is pending until its data resolves; then its content is streamed and swapped in, and in the browser it later hydrates. If its component throws on the server, React sends no content for it. It marks the boundary for client rendering (the comment becomes <!--$!-->), and the browser renders that part itself when the JavaScript loads.

A state machine for one Suspense boundary. Fallback, sent in the shell chunk, moves to streamed when its data is ready, with the content in a hidden div. Streamed moves to swapped when the inline script runs. Swapped moves to interactive when its code loads and it hydrates. From fallback, if the component throws on the server or the render is aborted, the boundary moves to server error, marked with a comment, then to client render when the JavaScript loads, and from there to interactive.data readyscript runshydratesfallbackin shell chunkstreamedhidden divswapped$RC raninteractivehandlers onthrows or abort()server errormarked with $!JS loadsclient renderfresh in browserrenders
The happy path runs along the top. If the component throws on the server, or abort() runs first, the boundary keeps its fallback and is rendered in the browser instead.

Check your understanding · Exercise 3

The shell has three boundaries, top to bottom: A (reviews, ready at 900 ms), B (Q and A, ready at 300 ms), C (rail, ready at 600 ms). With out-of-order streaming, in which order do their chunks arrive? Pick one.

Selective hydration: interactive piece by piece

Streaming gets pixels on screen early. But a button in server HTML does nothing until hydration has attached its click handler. Classic hydration was the second all-or-nothing wait, and streaming alone doesn't fix it. Selective hydration does.

How hydration used to work

Before React 18, ReactDOM.hydrate walked the whole tree in one go. It could start only after the JavaScript for every component on the page had loaded, and once started it ran as one long task: on a mid-range phone, 300 to 800 ms for a big page during which taps and scrolls had to wait. A tap on Buy before the code loaded did nothing at all, and a tap during the long task was handled only when the whole page had finished.

What changes with Suspense boundaries

  • Hydrate before all the HTML has arrived. The shell hydrates as soon as its code is loaded, while the reviews are still streaming. Pending boundaries are skipped and hydrated later.
  • Hydrate before all the code has arrived. If a boundary's component is code split with lazy, the rest of the page hydrates without waiting for that chunk of JavaScript.
  • Hydrate in small tasks. React hydrates one boundary at a time and gives the main thread back to the browser between them, so scrolling and typing stay responsive.
  • Hydrate what the user touches first. If the user clicks inside a boundary that isn't hydrated yet, React hydrates that boundary right away, ahead of the others, and then replays the click so it isn't lost.
Two hydration timelines from 0 to 1.2 seconds. One pass, before React 18: download all the JavaScript until 600 ms, then one long hydration task until 1,050 ms; the Buy button works at 1.05 seconds. Selective hydration: the shell's code loads by 300 ms and the shell hydrates by 380 ms, so the Buy button works at 0.38 seconds; the rail hydrates at about 650 ms and the reviews at about 960 ms, each in its own short task.One passdownload all JShydrate allBuy works at 1.05 sSelectiveshell JSshellrailreviews00.3 s0.6 s0.9 s1.2 sBuy works at 0.38 s
The one-pass page does nothing for taps before its code loads, and makes taps during its long task wait for the end. With selective hydration the shell is live early, and each boundary hydrates in its own short task when its HTML and code are there.

The last point is the clever one. React listens for events at the root of the app. When a click lands on DOM that belongs to a dehydrated boundary, React raises that boundary to the top of its queue, hydrates it and the boundaries on the path above it synchronously, and then dispatches the click to the newly attached handler. Continuous events, such as hovering over a boundary or moving focus into it, also raise its priority, but less urgently than a click.

Try it in the lab. Pick a part to click while hydration is running and watch the queue change.

The user clicks
Hydration

The click on Helpful on a review at 50 ms moves that part up, right after the shell task. Order: Shell, Reviews, Rail, Chat widget. The click is replayed and handled at 200 ms.

Hydration timeline from 0 to 500 ms. The click on Helpful on a review at 50 ms moves that part up, right after the shell task. Order: Shell, Reviews, Rail, Chat widget. The click is replayed and handled at 200 ms.Shell80 msReviews200 msRail300 msChat widget450 msclick at 50 ms0100200300400500
A simple model: the shell hydrates first in 80 ms, then reviews (120 ms), rail (100 ms), and chat (150 ms), each as its own task. The clicked part jumps the queue once the current task ends; with one pass, every click waits until the whole page is done.

What it doesn't fix

Selective hydration changes the order of the work, not the amount. The browser still downloads, parses, and runs every component's code, and every boundary still hydrates in the end. If the page ships 400 KB of JavaScript, it still ships 400 KB. To ship less, you need islands or React Server Components, which leave static parts out of the bundle entirely.

Mismatches still matter, too. If the browser's first render doesn't match the server HTML (a date formatted in a different time zone, a random id), React throws away that boundary's server HTML and renders it again in the browser. With boundaries, the damage stays inside one boundary, but it costs time and can shift the layout.

Check your understanding · Exercise 4

The shell has hydrated. Reviews and the rail are streamed but not hydrated yet. The user clicks "Helpful" on a review. What does React 18 or later do? Pick one.

Status codes, headers, and errors after the first flush

Streaming has one rule that surprises everyone the first time: the status code and headers leave with the first chunk, and after that they are locked. In HTTP/1.1 they are the first lines of the response; in HTTP/2 they are the HEADERS frame that comes before any DATA frame. The browser may already be painting your shell. There is no way to take them back.

  • You can't turn a 200 into a 404 because the product turned out not to exist.
  • You can't send a 302 redirect with a Location header because the user turned out to be logged out.
  • You can't add a Set-Cookie header, for example to refresh a session, from a component that renders later.
  • You can't change Cache-Control because a late boundary turned out to be personalised.

HTTP does have trailers, headers sent after the body, but browsers don't use them for status or cookies. So the fix is a design rule, not a header trick: anything that decides the status, redirects, cookies, or caching must be known before the shell is flushed.

A flowchart. For each piece of data the page needs, ask: does it decide the status, a redirect, cookies, or caching? If yes, check it before the first flush, in middleware, a loader, or the shell, and you can still send a real 404, a 302 redirect, or a Set-Cookie header. If no, put it in a Suspense boundary; it streams in later and the status stays 200, so any late redirect or not-found becomes a client-side redirect or a noindex tag.data neededdecides status, redirect,cookies, or caching?yesnocheck before flushmiddleware, loader, shellSuspense boundarystreams in laterreal 404, 302, cookieheaders still openstatus stays 200late: client redirect, noindex
Fast facts that decide the status and headers go before the flush. Everything else can stream. A decision that arrives after the flush can only be acted on inside the page.

Decide early: the usual pattern

Do the deciding checks before rendering starts, or in the shell. Frameworks give you a place for this: middleware, route loaders, or the page component itself, outside any boundary. The checks are usually fast (does this id exist, is there a valid session), so they cost little TTFB. The slow, decorative data goes inside boundaries, where a failure can only affect its own section.

tsx
// Next.js App Router: the existence check is in the shell
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id); // fast, cached
  if (!product) notFound(); // runs before the first flush: real 404

  return (
    <>
      <ProductHero product={product} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews id={id} /> {/* slow, after the flush */}
      </Suspense>
    </>
  );
}
Because notFound() runs before anything suspends, the response hasn't started and the status can still be 404. A loading.tsx for this route would change that: its fallback starts the stream before the page's own code runs.

When the decision comes too late

Sometimes the deciding fact only shows up inside a boundary, and frameworks then fall back to in-page tools. Next.js, for example, documents that streamed responses keep their 200 status. A redirect() that runs after the flush inserts a meta tag that redirects in the browser, and a notFound() renders the not-found UI with a meta name="robots" content="noindex" tag so search engines leave the URL out. That works for users, but anything that only reads status codes, such as monitoring, sees a success, and some crawlers report a soft 404.

Errors inside and outside boundaries

Where it failsWhat the user getsWhat to do
In the shell, before the flushNothing streamed yet; onShellError firesSend a real 500 error page
Inside a boundary, on the serverThe fallback, then React renders that part in the browserLog it via onError; keep a client error boundary around it
Inside a boundary, also in the browserThe nearest error boundary's UIShow a small "couldn't load reviews" message with a retry
A boundary that never resolvesA skeleton forever, until abort()Abort after a time limit, such as 5 to 10 s

Think first

Your dashboard streams. The session check happens inside a Suspense boundary around the account panel. What goes wrong when the session has expired?

What breaks streaming in production: buffers on the path

Streaming works only if every hop between your render code and the browser passes bytes on as they come. Any hop that collects the whole body before sending it, a buffer, quietly turns your streamed page back into a classic one. Nothing errors. The page just arrives all at once, and the TTFB is as slow as before.

The path of a streamed response as a stack of five layers, top to bottom: React's pipe, which writes each chunk; compression, which must flush after each write; a reverse proxy such as nginx, which needs proxy_buffering off; a CDN or edge, which must not rewrite the full body; and the browser, which parses bytes as they land. The middle three can buffer the response.React pipe(res)writes each chunkcompressionmust flush each writereverse proxyproxy_buffering offCDN or edgeno full-body rewritesbrowserparses as bytes land
Streaming survives only if every layer passes bytes on as they come. The hatched layers are where buffers usually hide; one of them is enough to turn the stream back into a single late response.

The usual suspects

  • Reverse proxies. nginx buffers proxied responses by default (proxy_buffering on). Turn it off for streamed routes with proxy_buffering off, or send the response header X-Accel-Buffering: no from the app, which nginx honours per response.
  • Compression. Gzip and Brotli encoders hold data until they have enough to compress well. A compression middleware must flush after each write. Node's compression middleware adds a res.flush() method, and React's pipe calls flush() on the destination when it exists.
  • Middleware that reads the body. Anything that rewrites, minifies, hashes, or logs the full HTML must wait for the end of it. That includes some CDN features that rewrite HTML at the edge.
  • Serverless platforms. A function that returns a whole response object can't stream. AWS Lambda, for example, needs its separate response streaming mode for this; other platforms differ, so check the docs for your host.
  • Networks you don't control. Some corporate proxies and antivirus tools buffer pages to scan them. You can't fix those, so the page must still work, just later, when everything arrives at once.
text
# nginx: let streamed pages through as they are written
location / {
  proxy_pass http://app;
  proxy_http_version 1.1;
  proxy_buffering off;
}

# Check from a terminal: -N turns off curl's own buffering
curl -N -s https://shop.example/p/42 | while IFS= read -r line; do
  printf '%s %s\n' "$(date +%T.%3N)" "${line:0:60}"
done
If every line shows the same timestamp, something on the path buffers. If the reviews lines arrive about 900 ms after the shell, streaming works. (%3N needs GNU date.)

How you notice

In Chrome DevTools, open the Network panel and click the document request. In the Timing tab, a streamed page has a short "Waiting for server response" (that is the TTFB) and a long "Content Download" that matches your slowest boundary. A buffered page shows the opposite: a long wait and a download of a few milliseconds. A filmstrip from WebPageTest or the Performance panel shows it too: skeletons first, then sections filling in.

The cost on the server

A streamed response holds its connection and its render state open until the slowest boundary finishes. With classic rendering the server also waited, but the request still took the same total time, so the cost doesn't change much. What does change is that a hanging dependency now holds open responses that users are already looking at. Always set a time limit with abort(), so a stuck reviews service turns into client-rendered fallbacks instead of thousands of half-finished pages.

Check your understanding · Exercise 5

Locally, the shell paints at 100 ms and reviews pop in at 900 ms. In production, DevTools shows a TTFB of 950 ms and a content download of 4 ms. What is the most likely cause? Pick one.

Effects on TTFB, FCP, LCP, CLS and INP, plus security and accessibility

Streaming moves several metrics at once, and not always in the same direction. Here is the product page from chapter 1 (shell data ready by 60 ms, reviews at 900 ms) on a mid-range phone, classic against streamed. The numbers are illustrative but realistic.

A bar chart comparing classic and streaming SSR for the product page. TTFB: 950 ms classic, 120 ms streaming. FCP: 1,300 ms classic, 500 ms streaming. LCP: 1,500 ms classic, 700 ms streaming, assuming the hero is in the shell.TTFB950 ms120 msFCP1300 ms500 msLCP1500 ms700 msclassic SSRstreaming SSR
Streaming cuts all three here because the hero is in the shell. Had the hero been inside the reviews boundary, the LCP bar would barely move while TTFB still dropped.
MetricClassic SSRStreaming SSRWhy
TTFBAbout 950 msAbout 120 msThe first chunk leaves when the shell is ready, not when the slowest call finishes
FCP (first contentful paint)About 1.3 sAbout 0.5 sThe head arrives first, so CSS and fonts load while the server is still working
LCP (largest contentful paint)About 1.5 sAbout 0.7 s, if the hero is in the shellOnly if the largest element is in the first chunk
CLS (cumulative layout shift)None from renderingRisk with badly sized skeletonsLate content can push the page around
INP (interaction to next paint)Long hydration task hurts early tapsSmaller tasks; clicked parts firstSelective hydration

The LCP traps

A great TTFB doesn't guarantee a good LCP. Three things commonly go wrong. First, the LCP element is inside a slow boundary, so the first chunk paints only skeletons, and LCP is as late as before. Second, the hero image's img tag arrives in the shell, but nothing tells the browser it matters, so it downloads at low priority: add fetchpriority="high", or a link rel="preload" in the head.

Third, and least obvious: LCP is the largest element painted so far, and the browser keeps updating it until the user interacts. If a late boundary streams in a block bigger than the hero, such as a long review list above the fold, that block becomes the new LCP element and the metric moves to 900 ms. Keep late content below the fold or smaller than the main content.

Layout shift

Every swap replaces a fallback with real content. If the skeleton is 120 px tall and the reviews are 480 px, everything below jumps by 360 px. Size each fallback like the content it stands for, use min-height or aspect-ratio for variable parts, and avoid boundaries above the main content, where a shift moves what the user is reading.

Security: inline scripts and CSP

Out-of-order streaming depends on inline scripts like $RC(...). A strict Content Security Policy blocks inline scripts unless they carry a matching nonce, a random value created for each response. React accepts a nonce option in renderToPipeableStream and renderToReadableStream and adds it to every script it writes. Send the same value in the Content-Security-Policy: script-src 'nonce-...' header, which is easy, because the headers go out before any script does.

Caching changes too. If any boundary is personalised, the whole response is personalised, because it is one HTTP response. A plain HTTP cache can't store the shell and let your server stream the personal parts into the same response. Either keep the page public and fetch personal parts separately, accept Cache-Control: private, or use a framework feature built for this split, such as Partial Prerendering (covered in the last chapter).

Accessibility

  • Mark each skeleton as loading: give its region an accessible name such as "Loading reviews" and aria-busy="true", so screen reader users know something is coming.
  • Don't announce every chunk through a live region. Three sections arriving means three interruptions. Announce only content the user asked for, such as search results.
  • Never move focus when a section arrives. The swap puts content in its document position, so reading order stays correct without extra work.
  • Respect prefers-reduced-motion for shimmering skeletons.

Check your understanding · Exercise 6

After moving to streaming, TTFB dropped from 1.1 s to 80 ms, but LCP only moved from 2.4 s to 2.2 s. Which change is most likely to fix LCP? Pick one.

In real products, frameworks, and the interview

You will rarely call renderToPipeableStream yourself. Every major meta-framework now streams, each with its own way to mark the holes. Knowing the names helps in interviews, because the interviewer may use their framework's words.

FrameworkHow you mark a holeNotes
Next.js App RouterA loading.tsx file, or Suspense around an async Server ComponentStreams by default; loading.tsx wraps the route segment in a boundary for you
React Router 7 (and Remix before it)Return a promise from the loader without awaiting it; render it with Await inside SuspenseAwaited loader data is in the shell; unawaited promises stream
SvelteKitReturn an unawaited promise from a server load function; use {#await} in the pagePromises are serialised into the stream as they resolve
SolidStartSuspense around components that read async resourcesStreams with out-of-order swaps, like React
Marko (eBay)The await tag with a placeholderDid out-of-order streaming in production years before React 18

Where the idea came from

Facebook's BigPipe (2010) split the home page into pagelets such as the news feed, friend requests, and ads. The server flushed the page layout first, then each pagelet as a script block carrying its HTML and the CSS and JavaScript it needed, and the browser placed each one in its slot. Facebook's 2010 post reported that, in its own lab tests, this roughly halved the latency users perceived in most browsers. Today's out-of-order streaming is the same idea, driven by components instead of hand-written pagelets.

A static shell with streamed holes

The newest step combines streaming with static generation. Next.js's Partial Prerendering (part of its Cache Components model in recent versions) builds the shell of a route ahead of time, serves it from the cache, and streams the dynamic holes, such as the cart or personal recommendations, into the same response. On a host that supports it, you get a CDN-speed TTFB for the shell and fresh data for the holes. The lesson Static generation and incremental regeneration covers the static half.

Decision A page has fast main content and slow secondary data. How do you serve it?

Classic SSR

Fetch everything, render everything, send one response.

Pros

  • Simplest; any status code or header at any time.
  • Works behind any proxy.

Cons

  • TTFB equals the slowest call.
  • One long hydration pass.

SSR shell, then fetch the slow parts in the browser

Server renders the main content.

Slow parts load with a client request after hydration.

Pros

  • Works with buffering infrastructure.
  • The shell can be cached at a CDN.

Cons

  • The slow request starts late, after the JavaScript runs.
  • Two data paths; crawlers miss the slow parts.

Streaming SSR

Shell in the first chunk; slow parts in Suspense boundaries, streamed and swapped in.

Pros

  • Early TTFB and FCP; all data fetched on the server, in parallel.
  • Selective hydration for free.
  • Crawlers get everything in one response.

Cons

  • Status and headers locked at the flush.
  • Needs a non-buffering path and CSP nonces.
  • Personalised holes make the whole response uncacheable.

What we pick: For a server-rendered page whose main content is fast and whose secondary data is slow, stream. Keep the client-fetch approach for parts that must not block caching, such as a personal greeting on a public page. If the page is the same for everyone, prefer static generation; the lesson Choosing a rendering strategy puts all of these side by side.

Using it in a system design interview

Bring streaming up when the page you are designing mixes fast, important data with slow, secondary data: product pages, news articles with comments, dashboards with many widgets, search results with facets. Say it in one sentence first: "I'd server render and stream: the shell with the main content goes out in the first chunk, and the slow sections stream into Suspense boundaries."

  1. Draw the page as a wireframe and circle the boundaries: what's in the shell, what's in each hole, and which element is likely the LCP element.
  2. Name the data behind each part with a rough latency, so the reason for each boundary is visible.
  3. Say what happens on the wire: head first so CSS and fonts start, shell next, holes out of order with swap scripts.
  4. Cover the browser side: selective hydration, the Buy button in the shell, heavy widgets lazy loaded in their own boundaries.
  5. Name the traps before the interviewer does: status codes locked at the flush, buffering proxies, layout shift from skeletons, CSP nonces.
LevelWhat a strong answer adds
JuniorStreaming sends the page in parts; slow parts show a skeleton first. Knows Suspense marks the parts.
Mid-levelShell versus boundaries, out-of-order swaps, selective hydration, and the effect on TTFB, FCP and LCP.
SeniorStatus and redirect decisions before the flush, buffering and compression on the path, CSP nonces, cache impact of personalised holes, abort timeouts, monitoring of boundary errors.
StaffWhen not to stream; static shells with streamed holes; how boundaries line up with code splitting and Server Components; rollout and measurement with field data.

Summary

  • Streaming server rendering sends the HTML in chunks: the head and a shell with the fast content first, then each slow part when its data is ready, on the same response.
  • Chunks are plain HTTP: Transfer-Encoding: chunked in HTTP/1.1, DATA frames in HTTP/2 and HTTP/3. Flushing the head early lets CSS, fonts, and the hero image start downloading at once.
  • In React, Suspense boundaries mark the holes. Everything outside them is the shell, and the shell's data sets the TTFB.
  • Out-of-order streaming sends each hole when it's ready, in any order, inside a hidden div with an inline script that swaps it into place. It needs JavaScript and a CSP nonce.
  • Selective hydration hydrates each boundary on its own, in small tasks, and hydrates the part the user clicks first, then replays the click.
  • Status codes, redirects, cookies, and cache headers are locked once the first chunk is sent. Decide them in middleware or the shell.
  • Proxies, compression, serverless runtimes, and HTML-rewriting middleware can buffer the response and silently undo streaming. Test in the real deployment.
  • Streaming improves TTFB and FCP almost always, but LCP only if the largest element is in the shell; badly sized skeletons cost CLS.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

You moved a product page to streaming SSR. TTFB dropped from 1.2 s to 90 ms, but LCP barely moved. What is going on, and how do you fix it?

Question 2

Walk me through the bytes a browser receives for a streamed page with one slow section.

Question 3

You deployed streaming behind nginx and a CDN, and the page still arrives in one piece. How do you debug it?

Question 4

How do you return a real 404 or a redirect when the page streams?

Question 5

What is selective hydration and why does it matter for INP?

Practice it

These design problems lean on streaming server rendering.

References

Read next