What you'll learn: By the end of this lesson you will be able to explain how React's streaming server renderer sends a shell followed by independent pieces, say what the shell is and where Suspense boundaries put its edges, place boundaries so slow data delays only what depends on it, predict what happens when a boundary fails, times out or sits in the shell, explain why the HTTP status is fixed at the first byte, recognise infrastructure that can silently undo streaming, and read the chunks React writes in a runnable lab.
Think of a restaurant that serves course by course: the bread arrives while the main dish is still in the oven. The alternative is a kitchen that brings everything at once, when the slowest dish is done. Classic server rendering is that second kitchen.
Streaming server rendering is the first kitchen. The server writes a page's HTML in several pieces over one HTTP response, and the browser shows each piece as it arrives. This lesson assumes you have read Rendering Strategies: CSR, SSR, SSG, ISR and Streaming, which compares the strategies, and Fiber, Scheduling and Concurrent Rendering, which covers Suspense on the client. Here we go deeper into the server half.
The running example is a product page for a shoe. The heading, price and "Add to cart" button are fast. Customer reviews and recommended products are slow and come from different services. The lab at the end renders exactly this page.
The core idea
- The server can write HTML before it has finished rendering. React's
renderToPipeableStream(the Node.js streaming API) hands HTML to a writable stream in pieces as parts of the tree become ready. - A Suspense boundary decides where the pieces split. Everything outside any boundary is the shell. A boundary that is not ready contributes its fallback to the shell and its real content later.
- Streaming changes when bytes leave, not how much work is done. And one thing becomes irreversible: the status line and headers leave with the first bytes.
Why this exists: three waits in classic SSR
React's working group, in a 2021 note on the Suspense server architecture, named three waits: the server had to collect all data before sending any HTML; the client had to load the JavaScript for every component before hydrating any of them; and hydration had to finish for the whole page before anything was interactive. Hydration means attaching React to HTML that already exists, so its event handlers work.
Streaming attacks the first wait; the same boundaries let the client hydrate parts independently and prioritise the part a user touches (the note calls this selective hydration). Hydration has lessons of its own (planned); this one touches it only where it matters for correctness.
What suspends, and what does not
A component suspends when it cannot finish rendering yet. The Suspense reference lists what activates a boundary: lazy-loading code with lazy, and reading a promise with use. It also says Suspense does not detect data fetched in an Effect or event handler, and the useEffect reference adds that Effects only run on the client, so such a component puts only its loading state into the server HTML.
The use reference adds that the promise must be cached so the same instance is reused: a promise created during render is recreated on every render and the fallback shows repeatedly. So a streaming page starts its data requests before rendering (in a route handler or a framework data layer), passes the promises down, and lets components read them with use.
Illustration 1: the minimal example. One boundary around one slow component:
import { Suspense, use } from "react";
function Reviews({ reviewsPromise }) {
const reviews = use(reviewsPromise); // suspends while the promise is pending
return (
<ul>
{reviews.map((review) => (
<li key={review}>{review}</li>
))}
</ul>
);
}
export function Page({ reviewsPromise }) {
return (
<main>
<h1>Trail Runner shoe</h1>
<Suspense fallback={<p>Loading reviews...</p>}>
<Reviews reviewsPromise={reviewsPromise} />
</Suspense>
</main>
);
}
The heading is the shell. "Loading reviews..." is the fallback that goes into the shell. The list arrives later. The lab's page is this component written with createElement so plain Node can run it without a build step.
The shell, the boundaries and the stream
react.dev defines the shell as "the part of your app outside of any <Suspense> boundaries". onShellReady fires when it has rendered, and that is usually where you start streaming. By then, components inside boundaries "might still be loading data".
Figure 1. Where the pieces of a streamed page come from
How to read this: one request, one response that stays open. The server writes what it can, then appends each piece as its promise settles.
Figure 2. One request, in order
How to read this: the status and the shell go out together, at the top. After that the server only appends, in the order data arrives, not the order of the page.
A server that does this with Node's http module is short. This is src/server.tsx from the lab; a test starts it on the loopback interface:
import { createServer } from "node:http";
import { renderToPipeableStream } from "react-dom/server";
export function createAppServer(loadData) {
return createServer((_request, response) => {
const { reviews, recommendations } = loadData();
const { pipe, abort } = renderToPipeableStream(
<Page layout="two-boundaries" reviews={reviews} recommendations={recommendations} />,
{
onShellReady() {
response.statusCode = 200;
response.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(response);
},
onShellError() {
response.statusCode = 500;
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end("<!DOCTYPE html><h1>Something went wrong</h1>");
},
onError(error) {
console.error(error);
},
},
);
const timer = setTimeout(() => abort(), 10_000);
response.on("close", () => clearTimeout(timer));
});
}
The callbacks are the contract. onShellReady fires when the shell is done: call pipe there. onShellError fires if the shell failed to render; per the reference no bytes have been emitted, so you can send another document and a real error status. onError fires for every server error, recoverable or not. abort gives up: React flushes the remaining fallbacks as HTML and tries to render the rest on the client. The reference has no timeout option, so a timer that calls abort bounds the wait.
What React actually writes
The lab records every chunk. The shapes below are what React 19.3.1 wrote on Node 26.12.1. They are implementation details, not an API: never write application code that depends on them.
- Chunk 1, the shell. The doctype, the heading, the price, the button, and for each pending boundary a marker comment, an empty
<template>with an id such asB:0, and the fallback markup. No closing</body></html>yet. - A boundary chunk. The boundary's content inside a hidden
<div>with an id such asS:1, then a small inline<script>that finds the template, removes the fallback and moves the content into its place. The first such chunk also carries the helper function. - The last chunk. The closing tags, once every boundary has settled.
react.dev says the swap works before React is on the page: "Streaming does not need to wait for React itself to load in the browser, or for your app to become interactive." Boundaries are revealed in the order their data arrives: recommendations that arrive first replace their fallback first, though reviews come first in the document.
Where to put the boundaries
The Suspense reference states the rule: "By default, the whole tree inside Suspense is treated as a single unit." If one component inside suspends, all of them show the fallback, and they appear together when all are ready. To reveal parts in sequence, nest boundaries.
| Layout | First chunk | When the slow content appears |
|---|---|---|
| No boundary around the slow parts | Nothing, until all data has arrived | All at once, inside the shell |
| One boundary around both | Shell and one combined fallback | Together, when the slower is ready |
| One boundary each | Shell and two fallbacks | Each when its own data arrives |
The lab runs all three.
Figure 3. Choosing a boundary
reason for the visit?"} -->|"yes, and it is fast"| A["Keep it in the shell"] Q1 -->|"yes, but it is slow"| B["Fix the data path first;
a fallback hides the main content"] Q1 -->|"no, it supports it"| Q2{"Is it slow or
unreliable?"} Q2 -->|"no"| A Q2 -->|"yes"| Q3{"Should it appear
with a neighbour?"} Q3 -->|"yes"| C["Share one boundary"] Q3 -->|"no"| D["Give it its own boundary"]
How to read this: a boundary is for supporting content that is slow or can fail. Wrapping the main content in a fallback only moves the wait onto the part the visitor came for.
Two consequences link to other lessons. A fallback should match the size of its content, or the page jumps when the content arrives (Cumulative Layout Shift and Layout Stability). And the element that decides the Largest Contentful Paint metric usually belongs in the shell (Loading Performance and Core Web Vitals).
Illustration 2: the realistic example. The product page has a fast shell (title, price, add to cart), a reviews section from a sometimes slow service, and a recommendations carousel from a slower one. Two boundaries, one each, is the right start: the button the shopper came for is in the shell, and buying does not need the reviews.
Failure paths: errors, timeouts and the status line
By the time something fails, the response may already be on the wire. The reference gives three cases.
An error in the shell. onShellError fires. No bytes have gone out, so the server can still choose a status and send another document. The lab's shell-error scenario shows no chunks and a 500.
An error inside a boundary. React does not abort the render: per the Suspense reference, it finds "the closest <Suspense> component above it" and includes its fallback in the server HTML. When the JavaScript loads, React retries that part on the client. If the retry succeeds, the visitor never learns of the server error, though onError on the server and onRecoverableError on the client still fire. If it fails, the nearest error boundary handles it.
A timeout. abort flushes the remaining fallbacks and leaves those parts to the client.
Figure 4. What happens to one boundary
How to read this: the server never throws a streamed page away because one boundary failed. The failed boundary becomes the client's job, and the visitor keeps the fallback until the client takes over.
The status line is committed once
The reference is blunt: "Once you start streaming, you can no longer set the response status code." This is HTTP, not React: the status line and headers precede the body. If the data decides whether a page is a 404, decide it before pipe is called, not inside a boundary. A product page that discovers inside a boundary that the product does not exist has already sent 200, and search engines treat a 200 page that says "not found" as a soft 404. The next lesson, SEO Architecture for Web Apps, covers what Google says about it.
Illustration 3: the failure case. The reviews service errors after the shell and recommendations went out. The lab's failed-boundary trace shows the status staying 200 (committed before onError), the fallback staying in the HTML, a final instruction handing that boundary to the client, and the recommendations untouched. That is the design working; the danger is monitoring that reads 200 as "everything worked".
Crawlers and onAllReady
onAllReady fires when all rendering is complete, "including both the shell and all additional content". The reference suggests pipe there for crawlers and static generation: "A regular visitor will get a stream of progressively loaded content. A crawler will receive the final HTML output after all the data loads." The caveat is also there: the crawler waits for all data, which may be slow or fail. The lab's crawler scenario writes one document with no fallbacks and no scripts.
Streaming and hydration
Streamed HTML is not interactive until hydrateRoot attaches React. Its reference says the tree must produce "the same output" as on the server, and that mismatches are bugs: at best a slowdown, at worst "event handlers can get attached to the wrong elements". React recovers from some by rendering on the client and reports them through onRecoverableError.
The lab's jsdom tests (jsdom is a JavaScript model of the DOM, not a browser) show both: before hydration a click does nothing; after hydrateRoot the same DOM node is kept and the click works; and when the client renders review text the server did not, onRecoverableError is called and the page ends up with the client's text.
Streaming through real infrastructure
Anything between Node and the browser can hold the response back.
- On HTTP/1.1, a response with no
Content-Lengthgoes out as chunks: MDN describes chunked transfer coding as the form where "the sender can keep a connection open" for streams of unknown size. The lab's server test confirms Node'shttpmodule sends the page withTransfer-Encoding: chunkedand noContent-Length. MDN adds that HTTP/2 disallows that header and has its own streaming mechanisms. - Reverse proxies may buffer. The nginx documentation says
proxy_bufferingis on by default, so nginx "receives a response from the proxied server as soon as possible, saving it into the buffers", and that it can be switched off per location or per response with theX-Accel-Bufferingheader. Other proxies, CDNs and compression layers have their own settings, which this lesson did not check: read your provider's documentation.
Figure 5. Where bytes can wait
How to read this: the server's code is correct if it writes early, and the visitor can still see the page arrive late if a layer in the middle holds the pieces. The question marks are the point: verify each hop.
Trade-offs
| Question | Plain SSR | Streaming SSR | Static (SSG) |
|---|---|---|---|
| First bytes | After all data is ready | After the shell is ready | From a prebuilt file |
| Status code | Chosen knowing everything | Chosen before the slow parts | Fixed at build time |
| Work per request | Full render | Same total, spread over time | None |
| Crawler output | Complete | Complete only if piped at onAllReady |
Complete |
| A part fails | Fails the page | Fallback, client retry | Fails the build |
| Infrastructure | Any HTTP server | A path that does not buffer | A CDN |
web.dev's Rendering on the Web warns that sending content sooner "doesn't necessarily give you less work to do", and that server rendering with rehydration can hurt Total Blocking Time and Interaction to Next Paint even while it improves First Contentful Paint. Streaming changes when the visitor sees something, not the JavaScript or hydration cost.
Build it: watch React stream a page
Goal. Predict, then read, the chunks renderToPipeableStream writes for the product page in seven situations: two boundaries, no boundary, one boundary, a crawler, a failed boundary, an abort and a shell error.
Setup. The lab is in the course's example workspace. It needs Node.js (captured on 26.12.1, which runs TypeScript directly) and pnpm.
cd examples/frontend
pnpm install
cd labs/streaming-ssr-trace
pnpm test # 18 tests: trace, a loopback HTTP server, hydration in jsdom
node src/demo.ts # prints the seven traces
Steps.
- Read
two-boundariesbelow. First write down how many chunks you expect and which boundary is revealed first. - Compare it with
no-boundaryin the full output: where isonShellReady, and why there? - In
one-boundary, find the chunk with the recommendations. The page still shows one fallback after it. Why? - In
failed-boundary, which comes first, the line committing the status or theonErrorline? - Read
test/server.test.tsx: it asserts what a real HTTP client has received before the data exists.
Expected output for the first scenario, captured from a real run (Node 26.12.1, React 19.3.1, jsdom 31.0.0, 2026-11-08). The lab prints React's boundary comments as {pending}, {end} and summarises its inline scripts; "page now shows" is the visible text after React's scripts ran in jsdom.
=== two-boundaries
lesson: Two Suspense boundaries: the shell goes first, each slow part is streamed when its data arrives, in any order.
layout: two-boundaries, pipe() called from onShellReady
--- trace
onShellReady
response: status 200 is now committed
chunk 1 written
html: <!DOCTYPE html><html lang="en"><head></head><body><h1>Trail Runner shoe</h1><p>Price: 89 (illustrative)</p><button type="button">Add to cart (0)</button>{pending}<template id="B:0"></template><p role="status">Loading reviews...</p>{end}{pending}<template id="B:1"></template><p role="status">Loading recommendations...</p>{end}<script>[timestamp bootstrap]</script>
page now shows (jsdom): Trail Runner shoe | Price: 89 (illustrative) | Add to cart (0) | Loading reviews... | Loading recommendations...
-- data arrives: recommendations
chunk 2 written
html: <div hidden id="S:1"><section aria-label="Recommendations"><h2>You may also like</h2><ul><li>Trail socks</li><li>Hydration vest</li></ul></section></div><script>[reveal B:1 using S:1 (includes the helper function)]</script>
reveals: B:1
page now shows (jsdom): Trail Runner shoe | Price: 89 (illustrative) | Add to cart (0) | Loading reviews... | You may also like | Trail socks | Hydration vest
-- data arrives: reviews
onAllReady
chunk 3 written
html: <div hidden id="S:0"><section aria-label="Reviews"><h2>Reviews</h2><ul><li>Light and grippy</li><li>Runs a half size small</li></ul></section></div><script>[reveal B:0 using S:0]</script>
reveals: B:0
page now shows (jsdom): Trail Runner shoe | Price: 89 (illustrative) | Add to cart (0) | Reviews | Light and grippy | Runs a half size small | You may also like | Trail socks | Hydration vest
chunk 4 written
html: </body></html>
page now shows (jsdom): Trail Runner shoe | Price: 89 (illustrative) | Add to cart (0) | Reviews | Light and grippy | Runs a half size small | You may also like | Trail socks | Hydration vest
response ends
Reading it: the status is committed with the shell, the recommendations were revealed first because their data came first, and onAllReady fires only when the last boundary has what it needs. In the other scenarios, no-boundary writes nothing until both data sources have arrived; one-boundary writes the recommendations early but keeps them hidden until the boundary completes; crawler pipes from onAllReady and writes one document with no fallbacks; shell-error writes no bytes and records a 500.
This lab is not a browser. The server half is real React on real Node, and the HTTP test uses a real server on the loopback interface. "What the visitor sees" is jsdom, which does no layout and paints nothing; React's reveal script runs there with its animation-frame and timer calls replaced by "run now". The lab shows order and final DOM state, never timing or what a proxy or CDN does, and ran on one React and one Node version.
Break it. In src/page.ts, in the default branch of the switch, replace h(Suspense, { fallback: fallback("Loading reviews...") }, reviews) with just reviews. The reviews become part of the shell, so nothing is written until their data arrives. Eight of the trace tests fail. Revert it.
Stretch.
- Nest a "photos" boundary inside the reviews and predict the reveal order before running it.
In practice: a product page whose reviews hold up the whole page
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A retail product page renders on the server for search and link previews. It loads the product, price, reviews and recommendations before sending anything, so every visit waits for the slowest source. The reviews service has long-tail slow responses (illustrative: a few seconds on bad days). Support hears "the page is blank for ages on a slow day". Search Console shows no errors.
Starting architecture
Figure 6. Starting architecture: nothing is sent until everything is ready
How to read this: the slowest dependency sets the time to first byte for the whole page, though the visitor needs only the product and the button to start.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Cache the rendered page | Fast repeat visits | Stale prices and reviews | Per-user data cannot be cached |
| Fetch reviews on the client | Server stops waiting | Reviews not in the HTML | Missing for crawlers and previews |
| Stream: shell, then boundaries | Shell written at once | Boundary design, a non-buffering path | A 200 is committed early |
The decision
| Field | Decision record |
|---|---|
| Context | One slow source delays the whole page and its first byte. |
| Decision | Stream. Product, price and the button stay in the shell. Reviews and recommendations get a boundary each, with fallbacks the same height as the content. "Product not found" is decided before pipe. abort after a fixed budget. |
| Consequences, good | The shopper can read and buy before the slow parts arrive. |
| Consequences, bad | The status is committed before reviews load; monitoring must not treat 200 as success; every hop must pass chunks through. |
| Revisit when | Fallbacks cause layout shift, or crawlers need reviews in the first response. |
Rollout and verification
- Look at the wire. Request the page with
curl -N(curl's--no-bufferoption) and watch the pieces arrive, from outside the network, through the CDN. - Check a 404. Request a product that does not exist and confirm the status is 404, not 200.
- Break a dependency on purpose in a test environment: the page should show the fallback, the shopper can still buy, and
onErroris logged. - Compare field data for First and Largest Contentful Paint and layout shift, for a share of traffic behind a flag.
The 2 a.m. failure
A platform change puts a new reverse proxy in front of the Node service. Tests pass and nothing errors. Over the next days support reports the page is slow again on bad days, and field first paint has regressed to its old level.
The proxy runs with response buffering on, which is nginx's documented default. With buffering on, nginx reads the upstream response into buffers as fast as it can instead of passing each piece straight through, so the shell and later pieces can reach the browser late and in lumps, much as before, while the application and its own tests look fine.
First three checks: (1) curl -N through the proxy and compare how the output arrives with a request straight to the Node port; (2) read the proxy's buffering settings for that location; (3) check whether the upstream already sends a header that controls buffering, such as X-Accel-Buffering for nginx. Mitigation: turn buffering off for the streaming location, or have the app send the header the proxy honours. Follow-up: a synthetic check, run through the real edge, that asserts the first chunk arrives before the slowest dependency is released.
Architect's notes
- Decide the status before streaming. Existence, authorisation and redirects happen before
pipe. - Treat the shell as a budget. Everything in it delays the first byte.
- Design the fallback. Same size as the content, accessible, never an empty gap.
- Bound every wait. Without
abort, a stuck dependency keeps the response open. - Monitor the stream, not only the status. Log
onError, countonRecoverableErrorcalls, and watch time to first byte apart from time to last byte. - Start with two or three boundaries, and add more only when a measurement shows one holds up content that does not depend on it.
Common mistakes
- Wrapping the main content in a boundary. The fallback becomes the page.
- Fetching in an Effect and expecting streaming. Suspense does not see Effects, and they do not run on the server.
- Creating the promise inside the component. It is recreated every render; create and cache it above the boundary.
- Deciding "not found" inside a boundary. The
200is already sent. - Trusting a passing server test. A buffering proxy hides the failure; test through the real path.
- Forgetting the timeout. Connections stay open as long as any boundary is pending.
- Reading React's chunk markers in application code. They are not an API and can change.
Key takeaways
- The shell is everything outside Suspense boundaries;
onShellReadyfires when it is rendered, and that is normally where you callpipe. - A boundary that cannot finish puts its fallback in the shell and its content later, with a small inline script that swaps it in.
- Everything inside a boundary appears together; nest boundaries to reveal in sequence. Boundaries reveal in the order data arrives.
- A render error inside a boundary leaves the fallback in the HTML and hands the boundary to the client;
onShellErroris the last point where the status can still be chosen. - The status line is committed with the first chunk. Decide 404, redirects and authorisation before streaming.
onAllReadygives a complete document for crawlers and static generation, at the price of waiting for everything.- Streaming changes when the first bytes leave, not the amount of work, and hydration must still match the server's HTML.
- Proxies, CDNs and compression can buffer the stream: verify each hop with a real request.
Practice exercises
Exercise 1 - Starter (30 min): Run the lab's demo. For one-boundary and two-boundaries, write what a visitor sees at each step and why the layouts differ.
Exercise 2 - Intermediate (1 hour): Add a scenario with the reviews boundary nested inside the recommendations boundary. Predict the chunk count and reveal order first.
Exercise 3 - Advanced (half day): Take a server-rendered page you know and list every dependency it waits for. For each, decide shell, own boundary or shared boundary, and what the fallback shows. Write the check that proves your proxy does not buffer it.
Related reading
In this course:
- Rendering Strategies: CSR, SSR, SSG, ISR and Streaming, the comparison this lesson deepens.
- Fiber, Scheduling and Concurrent Rendering, Suspense and transitions on the client.
- SEO Architecture for Web Apps, crawlers and status codes for a streamed page.
- Loading Performance and Core Web Vitals and Cumulative Layout Shift and Layout Stability.
Planned lessons cover hydration and React Server Components. Elsewhere on the site: System Design: Request Lifecycle.
Official documentation
- react.dev: renderToPipeableStream, the shell, callbacks,
abortand the status-code limit. - react.dev: Suspense and use, what suspends, revealing together and caching promises, plus useEffect (Effects run only on the client).
- react.dev: hydrateRoot, hydration and mismatches.
- React working group: New Suspense SSR Architecture in React 18, the three waits and selective hydration.
- web.dev: Rendering on the Web, the trade-offs of streaming and rehydration.
- MDN: Transfer-Encoding and nginx: proxy_buffering, chunked responses and proxy buffering.