renderToPipeableStream

Streaming with renderToPipeableStream

renderToString makes the reader wait for the slowest part of the page. renderToPipeableStream does not: it sends the shell — everything outside your Suspense boundaries — as soon as it is ready, keeps the connection open, and pushes each boundary's markup down the same response when its data arrives. React 7,897 renders the whole document here, <html> included, and bootstrapModules injects the hydration script.

stream.jsx - a shell now, the slow part laterJSX
import { createServer } from 'node:http';
import { Suspense, use } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
const reviews = () =>
  new Promise((ok) => setTimeout(() => ok(['Solid build', 'Fast delivery']), 1000));
const Reviews = ({ promise }) => <ul>{use(promise).map((t) => <li key={t}>{t}</li>)}</ul>;
const Page = () => (
  <html lang="en">
    <head><title>Keyboard</title></head>
    <body>
      <h1>Keyboard</h1>
      <Suspense fallback={<p>Loading reviews...</p>}><Reviews promise={reviews()} /></Suspense>
    </body>
  </html>
);
createServer((req, res) => {
  const { pipe } = renderToPipeableStream(<Page />, {
    bootstrapModules: ['/client.js'],
    onShellReady() { res.writeHead(200, { 'Content-Type': 'text/html' }); pipe(res); },
    onShellError: () => res.writeHead(500).end('<h1>Server error</h1>'),
  });
}).listen(4001, () => console.log('Streaming server on http://localhost:4001/'));

Timestamping each chunk of the response shows the two deliveries: the reviews take a second, the heading does not wait for them.

Output of 193
[+  37 ms] <!DOCTYPE html><html lang="en"><head><link rel="modulepreload" href="/client.js"/>
           <title>Keyboard</title></head><body><h1>Keyboard</h1><!--$?-->
           <template id="B:0"></template><p>Loading reviews...</p><!--/$-->
           <script type="module" src="/client.js" id="_R_" async=""></script>
[+1042 ms] <div hidden id="S:0"><ul><li>Solid build</li><li>Fast delivery</li></ul></div>
           <script>$RB=[];$RV=function(a){...};$RC("B:0","S:0")</script>
[+1042 ms] </body></html>

The mechanism is visible there. The fallback ships inside a <!--$?--> marker with an empty <template id="B:0"> beside it; when the promise resolves, React appends the finished list in a hidden <div> and an inline script — 862 bytes, sent once however many boundaries follow — swaps it into the marker's place. No framework is involved, so the swap happens even before your bundle has downloaded.

The callbacks decide the response. onShellReady fires when everything outside the boundaries has rendered: pipe there for a page that streams. onAllReady fires when every boundary has resolved: pipe there for crawlers, since streaming to a client that runs no scripts would leave it reading "Loading". onShellError is your only chance to send a 500, because once the first byte is out the status code is fixed. Errors inside a boundary are different: React keeps the fallback, reports them to onError, and lets the client retry that subtree while hydrating.