cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Streaming server rendering

Stream React's HTML with renderToPipeableStream and renderToReadableStream: the shell first, each Suspense boundary as its data arrives, chunk by chunk.

By the end, you can
  • 01
    Read a streamed pageTell the shell from later chunks, and say how a hidden chunk and a small script replace a Suspense fallback in the browser.
  • 02
    Stream from a serverUse renderToPipeableStream or renderToReadableStream with onShellReady, onAllReady, onShellError, and onError, and give up on slow parts with abort.
  • 03
    Choose streaming or prerenderingExplain how streamed boundaries hydrate one at a time, and when to wait for everything with onAllReady or prerender instead.

A product page that arrives in pieces

In Server rendering and hydration, the server called renderToString and sent the whole page at once. That's fine while every part is quick. But a real product page waits for data: the details from one service, the reviews from another. renderToString can't wait, so it sends fallbacks, and prerender waits for everything, so one slow service holds up the whole page.

Streaming, which Rendering strategies showed in a list of chunks, does better than both. The server sends what's ready at once, with a Suspense fallback where each slow part goes, and keeps the response open. As each part's data arrives, it sends that part's HTML down the same response, and the browser puts it in place. Here is a Node.js server that streams a tea shop's product page. It runs on a server, so it has no result here:

server.jsx: streaming the product page (Node.js)JSX
import { createServer } from "node:http";
import { renderToPipeableStream } from "react-dom/server";
import Document from "./Document.jsx";
import ProductPage from "./ProductPage.jsx";

createServer((request, response) => {
  const { pipe } = renderToPipeableStream(
    <Document>
      <ProductPage />
    </Document>,
    {
      bootstrapScripts: ["/main.js"],
      onShellReady() {
        response.setHeader("Content-Type", "text/html");
        pipe(response);
      },
    },
  );
}).listen(3000);

Line 2 imports renderToPipeableStream, React's streaming renderer for Node.js. Lines 7 to 10 render the product page inside Document, a component that renders the <html>, <head> and <body> tags, so React writes the whole document. Line 12 asks React to add a script tag for the browser's main.js. Lines 13 to 16 are onShellReady: React calls it as soon as the first part of the page is ready, and line 15 starts sending, by piping React's stream into the response.

In ProductPage, the details take 300 ms and the reviews 600 ms, and each sits in its own Suspense boundary. So the browser receives one response in three moments:

Three page windows in a row. At 0 milliseconds, the shell: Green tea, Loading details, Loading reviews. At 300 milliseconds: Green tea, the details, Loading reviews. At 600 milliseconds: Green tea, the details, and the reviews. Arrows lead from each window to the next.pageGreen teaLoading details...Loading reviews...0 ms: the shellpageGreen teaLoose leaf, $4Loading reviews...300 ms: detailspageGreen teaLoose leaf, $45 stars, Asha600 ms: reviews
One response, three moments. The shell arrives at once with a fallback for each Suspense boundary. Each boundary's HTML follows in the same response when its data is ready, and replaces its fallback.
Real-life analogyA meal served course by course

A good restaurant doesn't wait for the slowest dish before bringing anything. It lays the table, brings the bread, and puts a card where each course will go. When a dish is ready, the waiter takes the card away and sets the dish down. You can start eating straight away.

In real life: The table is laid and the bread arrives at once
In React: The shell arrives at once
In real life: A card on the table says "soup coming"
In React: A Suspense fallback holds a boundary's place
In real life: Each dish comes out when the kitchen has it ready
In React: Each boundary's HTML streams in when its data arrives
In real life: The waiter swaps the card for the dish
In React: A small script swaps the fallback for the content
In real life: One slow dish doesn't keep you from eating the bread
In React: One slow service doesn't hold up the rest of the page
The main idea

Streaming server rendering sends a page's HTML in pieces over one response. React sends the shell, everything outside Suspense boundaries plus their fallbacks, as soon as it's ready. Each boundary's HTML follows when its data arrives, with a small script that puts it in place of the fallback. The browser hydrates each piece on its own.

The shell goes first

React can't send a boundary's content before its data arrives, but it can send everything else. That first part is the shell: every element outside every Suspense boundary, plus each boundary's fallback. In this page, the heading and the Add to cart button, from AddToCart, are outside, and the details and the reviews are inside:

A component tree. ProductPage at the top; under it h1, AddToCart, and two Suspense boundaries, one around Details and one around Reviews. The left part, h1 and AddToCart, with the two fallbacks, is labelled the shell: sent at once. Details and Reviews are labelled: sent later, each in its own chunk.ProductPage<h1>AddToCartSuspenseSuspenseDetailsReviewsthe shell: sent at oncewith both fallbackssent later, one chunk each
The shell is everything outside every Suspense boundary, plus each boundary's fallback. React sends it as soon as it's ready, then each boundary's content when its data arrives.

Here is the page streaming for real, in the result frame. react-dom/server has a build for browsers, so this example renders the same ProductPage there and adds each chunk to the box at the top, which stands for the visitor's page. Below it, the list shows every chunk of HTML with the time it arrived. Press Run again to watch it again:

main.jsx: streaming ProductPage into the visitor's pagePop out in the code editor (opens in a new tab)JSX
import { renderToReadableStream } from "react-dom/server";import ProductPage from "./ProductPage.jsx";import { finish, showChunk } from "./show.js"; const started = performance.now();const stream = await renderToReadableStream(<ProductPage />);const reader = stream.getReader();const decoder = new TextDecoder(); while (true) {  const { done, value } = await reader.read();  if (done) break;  showChunk(decoder.decode(value), Math.round(performance.now() - started));}finish();
ResultLoading…

Line 6 starts the stream. renderToReadableStream is the Web Streams version of the same renderer; its promise resolves once the shell is ready. Lines 10 to 14 read the stream one chunk at a time, and line 13 hands each chunk and its time to showChunk, in the show.js tab. It adds the chunk to the visitor's page the way a browser adds streamed HTML, running its scripts. The data comes from the data.js tab: 300 ms for the details, 600 ms for the reviews.

The first chunk arrives at once, holding the heading, the button and both fallbacks. Press Edit & run and, in the data.js tab, change the reviews' 600 on line 13 to 2000: the details still come at 300 ms, and the reviews two seconds in. Then open ProductPage.jsx and remove the Suspense boundary around <Reviews /> on lines 14 to 16, keeping <Reviews />: the reviews are now part of the shell, and nothing arrives until they're ready.

Where the boundaries go decides what the shell holds

A component that waits outside every boundary holds up the whole page, so put a boundary around each part that waits for slow data. Keep the important things, like the heading, the price and the main button, outside the boundaries, so they arrive first.

How a late chunk takes its place

A chunk that arrives later can't go back and change the HTML the browser already has. So React sends the content at the end of the page, hidden, together with a small script that moves it where it belongs. Step through the recorded stream of the Node.js server above, one chunk at a time:

Playground: a stream, chunk by chunk
HTML the browser has received0 of 4 chunks

Nothing yet. The server is rendering the shell.

What the visitor seesa blank tab

The browser is waiting for the first byte.

Step 0 of 4request sent
Every answer arrives

The details take 300 ms and the reviews 600 ms. Press Next chunk to receive the first piece of HTML.

The chunks were recorded from react-dom/server 19.3.1 in Node.js, a production build, with the timings in data.js. The lesson's test records them again and checks every chunk and what the page shows after it. Since React 19.2, a reveal can wait a moment so that boundaries arriving close together appear together; this replay shows each as soon as its chunk arrives.

Press Next chunk once. The first chunk is the whole shell. Each fallback sits between <!--$?--> and <!--/$-->, two HTML comments that mark a boundary still waiting, and a <template id="B:0"> gives the boundary an id. Press it again: the details arrive at 300 ms, inside <div hidden id="S:0">, which the browser doesn't show. The script after it defines React's swap functions, $RC and $RV, then calls $RC("B:0", "S:0"): put the content of S:0 into boundary B:0. The visitor's page now shows the details.

Left, chunk 1 at 0 milliseconds: the markers dollar question, template id B:0, the fallback paragraph Loading details, and the closing marker. Right, chunk 2 at 300 milliseconds: a hidden div with id S:0 holding the details, and a script calling $RC with B:0 and S:0. An arrow from the hidden div to the fallback: the details move in, the fallback goes.chunk 1, 0 ms<!--$?--><template id="B:0"><p>Loading details...</p><!--/$-->chunk 2, 300 ms<div hidden id="S:0"><p>Loose leaf…</p>$RC("B:0", "S:0")the details move in; the fallback goes
Chunk 1 puts the fallback between two comment markers and gives the boundary an id, B:0. Chunk 2 sends the content in a hidden <div id="S:0">, then calls $RC("B:0", "S:0"), which moves it into the boundary and removes the fallback. The browser does this before React's JavaScript has even loaded.

This happens before any of your JavaScript has loaded: the swap is plain DOM code that the server sent. Then try The reviews fail and No boundary around the reviews, and read what each chunk says. Each row of the table is a piece of a streamed boundary, what it means, and who writes it:

Reading a streamed boundary
In the HTMLWhat it meansWho writes it
<!--$?--> … <!--/$-->A boundary still waiting for its content. The fallback sits between the two comments.The server, in the shell.
<template id="B:0">The boundary's id, so a later chunk can find it.The server, in the shell.
<div hidden id="S:0">The boundary's content, hidden until it's moved into place.The server, in a later chunk.
$RC("B:0", "S:0")Moves the hidden content into the boundary and removes the fallback.A script in the later chunk, which the browser runs as it arrives.
<!--$~-->Content has arrived and is waiting a moment to be shown with others (React 19.2 and later).$RC, until React's reveal function $RV runs.
<!--$-->A finished boundary, with its content in place.The server, for a boundary ready in time, or $RV after a swap.
<!--$!-->, $RX("B:1")A boundary the server couldn't finish. The browser renders it when it hydrates.$RX, after an error or when the server gave up.

Since React 19.2, $RC marks a boundary <!--$~--> and lets $RV reveal it, waiting up to about 300 ms after the previous reveal, so boundaries that arrive close together appear together. In this page they're 300 ms apart, so each shows as it arrives. You can read these scripts in React's source, in the fizz-instruction-set folder (Fizz is the name of React's server renderer).

Node.js streams and Web streams

React has two streaming renderers, because servers speak two kinds of stream. Node.js has its own streams, and renderToPipeableStream writes to one. Deno, edge runtimes like Cloudflare Workers, and browsers use the standard Web Streams, and renderToReadableStream returns one. Since React 19.2 it works in Node.js too, but React still recommends renderToPipeableStream there, because Node.js streams are faster in Node.js. Here is the Node.js server again, with every callback:

server.jsx: every callback, a status code, and a time limit (Node.js)JSX
import { createServer } from "node:http";
import { renderToPipeableStream } from "react-dom/server";
import Document from "./Document.jsx";
import ProductPage from "./ProductPage.jsx";

createServer((request, response) => {
  let failed = false;
  const { pipe, abort } = renderToPipeableStream(
    <Document>
      <ProductPage />
    </Document>,
    {
      bootstrapScripts: ["/main.js"],
      onShellReady() {
        response.statusCode = failed ? 500 : 200;
        response.setHeader("Content-Type", "text/html");
        pipe(response);
      },
      onShellError(error) {
        response.statusCode = 500;
        response.setHeader("Content-Type", "text/html");
        response.end("<h1>Something went wrong</h1><p>Please try again.</p>");
      },
      onAllReady() {
        console.log("Every Suspense boundary is done");
      },
      onError(error) {
        failed = true;
        console.error(error);
      },
    },
  );
  setTimeout(() => abort(), 10000);
}).listen(3000);

Lines 14 to 18 are onShellReady. Line 15 sets the status code; the status goes out with the first chunk, so it has to be decided before pipe. Lines 19 to 23 are onShellError: the shell itself failed, React has sent nothing, and line 22 sends a small error page instead. Lines 24 to 26 are onAllReady, once every boundary is done. Lines 27 to 30 are onError, for any error, in the shell or a boundary; line 28 remembers it. Line 33 calls abort() after 10 seconds: any boundary still waiting stops waiting, and the browser renders it instead.

Each row is a callback, when React calls it, and what you do there:

The callbacks
CallbackReact calls it whenWhat you do there
onShellReadyThe shell has rendered: everything outside every Suspense boundary.Set the status code and headers, then call pipe(response).
onShellErrorThe shell couldn't render.Send your own error page. React has sent nothing yet.
onAllReadyEvery boundary is done, after the shell.Pipe here instead, for crawlers or a page saved to a file, to send complete HTML.
onErrorAny error while rendering, in the shell or in a boundary.Log it, and remember it for the status code. What it returns is sent as the boundary's digest.

The same server for a Web Streams runtime returns a Response:

handler.jsx: streaming with Web StreamsJSX
import { renderToReadableStream } from "react-dom/server";
import Document from "./Document.jsx";
import ProductPage from "./ProductPage.jsx";

export async function handleRequest(request) {
  try {
    const stream = await renderToReadableStream(
      <Document>
        <ProductPage />
      </Document>,
      {
        bootstrapScripts: ["/main.js"],
        signal: AbortSignal.timeout(10000),
        onError(error) {
          console.error(error);
        },
      },
    );
    return new Response(stream, { headers: { "Content-Type": "text/html" } });
  } catch (error) {
    return new Response("<h1>Something went wrong</h1>", {
      status: 500,
      headers: { "Content-Type": "text/html" },
    });
  }
}

Line 7 waits for the shell: the promise resolves when it's ready, and rejects if it fails, which lines 20 to 25 turn into an error page. Line 13 passes an AbortSignal that fires after 10 seconds, the Web Streams way to give up. Line 19 returns the stream as the response body. Each row compares the APIs:

Which API
APIWhere it runsWhat you get back
renderToPipeableStreamNode.js.{ pipe, abort }, at once. You call pipe from onShellReady or onAllReady.
renderToReadableStreamWeb Streams: Deno, edge runtimes, browsers, and Node.js since React 19.2.A promise of a stream, once the shell is ready. It rejects if the shell fails, and stream.allReady waits for the rest.
prerenderToNodeStream, prerenderBuild steps and servers, from react-dom/static (React 19).A promise of { prelude }, once every boundary is done. Nothing streams early.

When something fails mid-stream

Where an error happens decides what it costs:

Two paths. Top: an error in the shell, for example in Header. It goes to onShellError; nothing has been sent, so you send your own error page with status 500. Bottom: an error inside a Suspense boundary, in Reviews. It goes to onError; the stream keeps the fallback and sends $RX for that boundary, and the browser renders Reviews itself when it hydrates.In the shell<Header> throwsonShellErroryour 500 pageInside a boundary<Reviews> throwsonError$RX("B:1")the fallback stays;the browser renders it
An error in the shell stops the whole response: React calls onShellError before sending anything, so you can send an error page. An error inside a boundary only costs that boundary: React calls onError, keeps the fallback, and leaves the boundary for the browser to render.
  • In the shell. React can't send half a shell, so nothing goes out. It calls onError, then onShellError, where you send your own error page with a 500 status, as lines 19 to 23 of the server do.
  • Inside a boundary. Only that boundary is lost. React calls onError, keeps the fallback, and sends $RX("B:1"), which marks the boundary <!--$!-->. When the page hydrates, React renders that boundary in the browser; if it fails there too, the nearest error boundary catches it. Try The reviews fail in the playground.
  • Too slow. abort(), or the signal you passed, gives up on every boundary still waiting, the same way: a fallback now, the browser's render later.

A production build sends $RX("B:1") with no message, so visitors can't read your server's errors; a development build adds the message and the component stack. Whatever onError returns is sent as the error's digest, a short code that lets you match the browser's report with your server's log.

One thing streaming changes: the status code goes out with the first chunk. If the shell is sent at 0 ms and the reviews fail at 600 ms, the visitor has already received 200. The reviews' fallback, and the browser's render after it, are what's left to handle it.

Hydrating while the HTML still arrives

The shell ends with the script tag for main.js, so the browser starts downloading it while the rest of the HTML is still on its way. On a real site, main.js hydrates the whole document:

main.js: the browser hydrates the streamed documentJSX
import { hydrateRoot } from "react-dom/client";
import Document from "./Document.jsx";
import ProductPage from "./ProductPage.jsx";

hydrateRoot(
  document,
  <Document>
    <ProductPage />
  </Document>,
);

Lines 5 to 10 hydrate document with the same Document and ProductPage the server rendered. It often runs before the stream has ended. That's what selective hydration is for: React hydrates the shell, and leaves each boundary that's still waiting. When its chunk arrives, $RV calls the retry function React left on the boundary, and React hydrates it then. This example does all of it in the result frame: it streams the page, and hydrates the visitor's page right after the first chunk. Click Add to cart straight away, then Helpful once the reviews are in:

main.jsx: hydrating after the first chunk, while the rest streamsPop out in the code editor (opens in a new tab)JSX
import { hydrateRoot } from "react-dom/client";import { renderToReadableStream } from "react-dom/server";import ProductPage from "./ProductPage.jsx";import { finish, showChunk } from "./show.js"; const started = performance.now();const stream = await renderToReadableStream(<ProductPage />);const reader = stream.getReader();const decoder = new TextDecoder();let hydrated = false; while (true) {  const { done, value } = await reader.read();  if (done) break;  showChunk(decoder.decode(value), Math.round(performance.now() - started));  if (!hydrated) {    // main.js arrives while the rest of the HTML is still streaming.    hydrateRoot(document.getElementById("visitor"), <ProductPage />);    hydrated = true;  }}finish();
ResultLoading…

Lines 16 to 20 call hydrateRoot once, after the first chunk, as main.js would. The button counts your clicks while the reviews are still on their way, and Helpful works as soon as the reviews appear: React hydrated each boundary when its chunk arrived.

The count lives in its own component, AddToCart (its own tab), on purpose: a click re-renders only the button. If the count lived in ProductPage, a click would re-render the boundaries' parent while they still wait for their HTML. React can't hydrate them at that moment, so it would drop the server's HTML for them and render them in the browser, fallbacks first. While the HTML streams, the page is still loading; if it had finished loading with a boundary still waiting, React would stop waiting and render that boundary itself. That's why show.js, lines 3 and 16 to 19, tells React the page is loading until the stream ends. You can read that check in registerSuspenseInstanceRetry, in React 19.3.1. Hydration inside shows how React leaves a waiting boundary and comes back to it.

Prerendering: wait for everything, ahead of time

Some pages are the same for every visitor, like a tea's description page. You can render them once, when you build the site, and save the HTML. Streaming doesn't help there: nobody is waiting. React 19 added prerender and prerenderToNodeStream, in react-dom/static, which wait for every Suspense boundary:

build.jsx: prerendering the product page to a file (Node.js)JSX
import { prerenderToNodeStream } from "react-dom/static";
import { createWriteStream } from "node:fs";
import Document from "./Document.jsx";
import ProductPage from "./ProductPage.jsx";

const { prelude } = await prerenderToNodeStream(
  <Document>
    <ProductPage />
  </Document>,
  { bootstrapScripts: ["/main.js"] },
);
prelude.pipe(createWriteStream("dist/green-tea.html"));

Line 6 resolves only when the details and the reviews are both in, so prelude holds the finished page: every boundary is <!--$-->, with no fallbacks and no swap scripts. Line 12 writes it to a file a web server can send as it is. A crawler gets the same complete HTML from a streaming server if you pipe in onAllReady instead of onShellReady, or, with Web Streams, after await stream.allReady.

React 19.2 went one step further: prerender can stop at the parts that change per request, and return a postponed state with its HTML. Later, for each request, resume (or resumeToPipeableStream) streams just the rest. This is called partial prerendering: the parts that are the same for everyone can be served at once from a cache, and the rest filled in for each request.

How frameworks stream your pages

You rarely call these APIs yourself; a framework does, and you choose where the Suspense boundaries go. Two common ones (checked in October 2026):

  • React Router, in its default server entry for Node.js (entry.server.node.tsx), calls renderToPipeableStream. It pipes in onShellReady for people and in onAllReady for crawlers it recognizes, and calls abort() after a timeout, like the server in this lesson.
  • Next.js has streaming built into its App Router. A loading.js file next to a page wraps the page in a Suspense boundary with that file as the fallback, and your own <Suspense> boundaries stream inside it.

In both, the shell is whatever you leave outside your boundaries. Put a boundary around each part that waits on slow data, keep the parts people need first outside, and let the rest stream. The React frameworks lesson covers frameworks. Sort these parts of the lesson's page by the chunk they arrive in:

First chunk or later?
  • <h1>Green tea</h1>
  • The Add to cart button
  • <p>Loading reviews...</p>
  • <template id="B:1">
  • The script tag that loads main.js
  • 5 stars from Asha
  • $RC("B:0", "S:0")
  • $RX("B:1"), when the reviews fail
Try it yourself
0 of 8 correct

The lesson's product page streams with every answer arriving: details at 300 ms, reviews at 600 ms. Sort each piece by the chunk it arrives in.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Streaming sends one request per boundary." No. It's one response that stays open; each chunk is more HTML in the same response.
  • "The browser needs React to show streamed content." No. The swap is a small script inside the HTML. The details appear before main.js has loaded; React only needs to arrive to make them work.
  • "Streaming makes slow data faster." No. The reviews still take 600 ms. What changes is that nothing else waits for them.
  • "An error after the shell can still turn into a 500 page." No. The status went out with the first chunk. A failed boundary keeps its fallback, and the browser renders it.
  • "Every component streams on its own." No. Only Suspense boundaries can arrive later. Anything outside them is part of the shell and holds it up.
  • "Crawlers see the fallbacks." Only if you send them the stream as it goes. Pipe in onAllReady for crawlers, or prerender the page.

Practice exercises

The live exercises stream a page in the result frame and print each chunk with its time in the console. The server exercises are Node.js code, so they have no result; check your answer against the lesson's callbacks.

Exercise 1 · Warm-upCount the chunks

How many chunks does this stream send? Answer, then press Edit & run and count the console lines.

import { Suspense, use } from "react";
import { renderToReadableStream } from "react-dom/server";

const later = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const stock = later(500, "12 in stock");
const reviews = later(900, "4 stars from Ravi");

function Stock() {
  return <p>{use(stock)}</p>;
}

function Reviews() {
  return <p>{use(reviews)}</p>;
}

function TeaPage() {
  return (
    <main>
      <h1>Jasmine tea</h1>
      <Suspense fallback={<p>Checking stock...</p>}>
        <Stock />
      </Suspense>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Reviews />
      </Suspense>
    </main>
  );
}

const started = performance.now();
const stream = await renderToReadableStream(<TeaPage />);
const reader = stream.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const ms = Math.round(performance.now() - started);
  console.log(`+${ms} ms:`, decoder.decode(value));
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the first chunk

    What does the first chunk show where the stock goes?

    import { Suspense, use } from "react";
    import { renderToReadableStream } from "react-dom/server";
    
    const stock = new Promise((resolve) => setTimeout(() => resolve("12 in stock"), 500));
    
    function Stock() {
      return <p>{use(stock)}</p>;
    }
    
    function TeaPage() {
      return (
        <main>
          <h1>Jasmine tea</h1>
          <Suspense fallback={<p>Checking stock...</p>}>
            <Stock />
          </Suspense>
        </main>
      );
    }
    
    const started = performance.now();
    const stream = await renderToReadableStream(<TeaPage />);
    const reader = stream.getReader();
    const decoder = new TextDecoder();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const ms = Math.round(performance.now() - started);
      console.log(`+${ms} ms:`, decoder.decode(value));
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeLet the page stream before the reviews

      The console shows one chunk, about 600 ms in: the whole page waits for the reviews. Make the heading arrive at once. How many chunks does the stream send after your fix?

      import { Suspense, use } from "react";import { renderToReadableStream } from "react-dom/server"; const reviews = new Promise((resolve) => setTimeout(() => resolve("5 stars from Asha"), 600)); function Reviews() {  return <p>{use(reviews)}</p>;} function ProductPage() {  return (    <main>      <h1>Green tea</h1>      <Reviews />    </main>  );} const started = performance.now();const stream = await renderToReadableStream(<ProductPage />);const reader = stream.getReader();const decoder = new TextDecoder();while (true) {  const { done, value } = await reader.read();  if (done) break;  const ms = Math.round(performance.now() - started);  console.log(`+${ms} ms:`, decoder.decode(value));}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePick the status code

        Add a status code to this server: 500 if onError has already fired when the shell is ready, 200 otherwise. Then, for the lesson's page, where the shell is ready at 0 ms and the reviews fail at 600 ms: what status code does the visitor get?

        Starter codeJSX
        createServer((request, response) => {
          const { pipe } = renderToPipeableStream(<Page />, {
            onShellReady() {
              response.setHeader("Content-Type", "text/html");
              pipe(response);
            },
            onError(error) {
              console.error(error);
            },
          });
        }).listen(3000);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeSend crawlers the whole page

          Change this server so crawlers get the complete page and people still get the shell first. For the lesson's page, how many milliseconds until the server starts sending to a crawler?

          Starter codeJSX
          createServer((request, response) => {
            const { pipe } = renderToPipeableStream(<Page />, {
              onShellReady() {
                response.setHeader("Content-Type", "text/html");
                pipe(response);
              },
            });
          }).listen(3000);

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeGive up on slow reviews

            Make the server give up on anything still waiting after 400 ms. Which function does the last chunk call for the reviews' boundary?

            import { Suspense, use } from "react";import { renderToReadableStream } from "react-dom/server"; const reviews = new Promise((resolve) => setTimeout(() => resolve("5 stars from Asha"), 600)); function Reviews() {  return <p>{use(reviews)}</p>;} function ProductPage() {  return (    <main>      <h1>Green tea</h1>      <Suspense fallback={<p>Loading reviews...</p>}>        <Reviews />      </Suspense>    </main>  );} const started = performance.now();const stream = await renderToReadableStream(<ProductPage />);const reader = stream.getReader();const decoder = new TextDecoder();while (true) {  const { done, value } = await reader.read();  if (done) break;  const ms = Math.round(performance.now() - started);  console.log(`+${ms} ms:`, decoder.decode(value));}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what's in the shell, and which chunk each part arrives in.

              The streaming quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What is the shell of a streamed page?

                Choose an answer to see the explanation.

              2. Question 2 of 8In the lesson's product page, which chunk holds Loading reviews...?

                Choose an answer to see the explanation.

              3. Question 3 of 8What does $RC("B:0", "S:0") do in the browser?

                Choose an answer to see the explanation.

              4. Question 4 of 8A search engine's crawler asks for the page. Which callback should you pipe the stream in?

                Choose an answer to see the explanation.

              5. Question 5 of 8Reviews throws on the server, inside its Suspense boundary. What does the visitor see first?

                Choose an answer to see the explanation.

              6. Question 6 of 8The heading component throws, outside every boundary. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 8Which of these waits for every Suspense boundary before sending any HTML?

                Choose an answer to see the explanation.

              8. Question 8 of 8The shell has arrived and main.js has hydrated it, but the reviews are still streaming. Can the visitor click Add to cart?

                Choose an answer to see the explanation.

              Key takeaways

              • Streaming sends a page's HTML in pieces over one response: the shell at once, then each Suspense boundary when its data is ready. Nothing waits for the slowest part.
              • The shell is everything outside every boundary, plus the fallbacks. Anything that waits outside a boundary holds up the whole page.
              • A later chunk sends the content hidden, then $RC moves it into the boundary in place of its fallback, before React's JavaScript has loaded.
              • Use renderToPipeableStream in Node.js and renderToReadableStream with Web Streams. Pipe in onShellReady for people, onAllReady for crawlers, handle onShellError, and give up on slow parts with abort or a signal.
              • A boundary that fails or times out keeps its fallback and is rendered by the browser. Boundaries hydrate one at a time as they arrive; prerender waits for everything, for pages built ahead of time.

              Remember the one-liner.
              Send the shell now, each boundary when it's ready, and let the browser swap and hydrate them one by one.

              Coming next: Metadata, stylesheets and resource hints. React 19 lets your components render the page's title, meta tags and stylesheets, and tells the browser what to download early.

              CompleteFrontend Clear concepts. Working examples.