renderToReadableStream

Streams a React tree as HTML into a Web ReadableStream for edge and worker runtimes.

Signature
const stream = await renderToReadableStream(reactNode, options?)
  • Added in React 18.0.0

Parameters

ParameterTypeDescription
$reactNodeReactNodeA React node you want to render to HTML. For example, a JSX element like <App />. It is expected to represent the entire document, so the App component should render the <html> tag.
$optionsoptionalobjectAn object with streaming options.
$options.bootstrapScriptContentoptionalstringIf specified, this string will be placed in an inline <script> tag.
$options.bootstrapScriptsoptionalstring[]An array of string URLs for the <script> tags to emit on the page. Use this to include the <script> that calls hydrateRoot. Omit it if you don't want to run React on the client at all.
$options.bootstrapModulesoptionalstring[]Like bootstrapScripts, but emits <script type="module"> instead.
$options.identifierPrefixoptionalstringA string prefix React uses for IDs generated by useId. Useful to avoid conflicts when using multiple roots on the same page. Must be the same prefix as passed to hydrateRoot.
$options.namespaceURIoptionalstringA string with the root namespace URI for the stream. Defaults to regular HTML. Pass 'http://www.w3.org/2000/svg' for SVG or 'http://www.w3.org/1998/Math/MathML' for MathML.
$options.nonceoptionalstringA nonce string to allow scripts for script-src Content-Security-Policy.
$options.onErroroptional(error: Error, errorInfo: object) => voidA callback that fires whenever there is a server error, whether recoverable or not. By default, this only calls console.error.
$options.progressiveChunkSizeoptionalnumberThe number of bytes in a chunk.
$options.signaloptionalAbortSignalAn abort signal that lets you abort server rendering and render the rest on the client.

Return value

Promise<ReadableStream & { allReady: Promise<void> }>A Promise. If the shell renders successfully, it resolves to a Readable Web Stream of HTML with an additional allReady property — a Promise that resolves when all rendering is complete, including both the shell and all additional content. If rendering the shell fails, the Promise is rejected.

Errors

The returned Promise rejects when the shell itself fails to render; errors inside a Suspense boundary do not reject it and are reported through onError instead.

Changelog

  • PHP 18.0.0Introduced as the Web Streams streaming SSR entry point for Deno and edge runtimes.
  • PHP 19.0.0The tree is expected to render the whole document including <html>; stylesheets, scripts and metadata rendered in the tree are hoisted into <head>.

renderToReadableStream is the Web Streams counterpart to renderToPipeableStream. The model is identical — a shell that must finish first, then Suspense boundaries flushed in as their data resolves — but the API is a promise and a ReadableStream instead of callbacks and pipe.

When to reach for it

Use it anywhere a Node writable stream does not exist: Deno, Bun, Cloudflare Workers, Vercel Edge, Netlify Edge, or any handler whose contract is Request in, Response out. Node also exposes Web Streams, but React's docs recommend the dedicated Node API there because it performs better.

What trips people up

Error handling is split in two, and people usually get this wrong once. The await only rejects if the shell fails; a failure inside a Suspense boundary never rejects, it calls onError and leaves the fallback in place for the client to retry. So wrap the await in try/catch for your 500 page, and use onError for logging everything.

The second trap is status codes: once you return the stream in a Response, the status is fixed. And for crawlers, remember to await stream.allReady before returning, or the HTML they index will contain your fallbacks.

Example

Example · jsx
import { renderToReadableStream } from 'react-dom/server';
import App from './App.js';

export default async function handler(request) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10_000);

  let didError = false;

  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      signal: controller.signal,
      onError(error) {
        // Fires for boundary errors too — these do NOT reject the promise.
        didError = true;
        console.error(error);
      },
    });

    const isCrawler = /bot|crawler|spider/i.test(
      request.headers.get('user-agent') ?? ''
    );
    if (isCrawler) {
      // Hold the response until every Suspense boundary resolved.
      await stream.allReady;
    }

    clearTimeout(timeoutId);

    return new Response(stream, {
      status: didError ? 500 : 200,
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    // Only reached when the SHELL failed to render.
    clearTimeout(timeoutId);
    return new Response(
      '<!DOCTYPE html><p>Something went wrong.</p>',
      { status: 500, headers: { 'content-type': 'text/html' } }
    );
  }
}

When to use it

  • Serving SSR HTML from a Cloudflare Worker or Vercel Edge function that returns a Response.
  • Awaiting stream.allReady so crawler requests get fully resolved HTML instead of fallbacks.
  • Cancelling a render when the client disconnects by forwarding request.signal.
  • Rendering an SVG document by passing namespaceURI for a dynamically generated image endpoint.

Frequently asked questions

Why doesn't my try/catch around renderToReadableStream catch the error?

Because the returned promise only rejects when the shell fails. An error thrown inside a <Suspense> boundary is treated as recoverable: React ships the fallback, calls onError, and lets the client retry, so the promise resolves normally. Track failures in onError and use that flag to choose your status code.

How do I make crawlers see the full page instead of Suspense fallbacks?

Await stream.allReady before returning the Response. That promise resolves only when the shell and every Suspense boundary have finished, so the body is a complete document. Do this for bot user agents only — awaiting it for real users throws away the streaming benefit.

Should I use renderToReadableStream or renderToPipeableStream in Node?

Use renderToPipeableStream in Node. Node exposes Web Streams so renderToReadableStream will run, but React's docs note it performs worse there and recommend the dedicated Node API. Reserve renderToReadableStream for Deno, Bun and edge runtimes that have no Node streams.

More examples

Cancelling when the client goes away

Forwarding the incoming request's AbortSignal means React stops rendering the moment the user navigates away or the connection drops, instead of burning CPU on a response nobody will read. When the signal fires before rendering finishes, unresolved Suspense boundaries stay as fallbacks and onError receives the abort reason along with a componentStack pointing at the boundary that was still pending.

Example · jsx
import { renderToReadableStream } from 'react-dom/server';

export default async function handler(request) {
  // request.signal aborts when the client disconnects.
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    signal: request.signal,
    onError(error, errorInfo) {
      console.error(error.message, errorInfo.componentStack);
    },
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

Where each error surfaces

Two failures, two completely different paths. The Layout error happens outside every Suspense boundary, so React cannot produce a shell and the awaited promise rejects — your catch block runs and nothing was sent. The Sidebar rejection happens inside a boundary, so the promise still resolves: the HTML contains 'Loading…', onError logs 'sidebar service down', and the client retries that subtree after hydration. Code that only uses try/catch will never notice the second failure.

Example · jsx
import { Suspense, use } from 'react';

function Layout({ children }) {
  // Throwing here breaks the SHELL -> the await rejects.
  if (!globalThis.config) throw new Error('Missing config');
  return <body>{children}</body>;
}

function Sidebar({ promise }) {
  // Rejecting here is INSIDE a boundary -> onError only.
  return <aside>{use(promise)}</aside>;
}

function App() {
  const promise = Promise.reject(new Error('sidebar service down'));
  return (
    <html lang="en">
      <Layout>
        <h1>Dashboard</h1>
        <Suspense fallback={<aside>Loading…</aside>}>
          <Sidebar promise={promise} />
        </Suspense>
      </Layout>
    </html>
  );
}

// try {
//   const stream = await renderToReadableStream(<App />, { onError: log });
//   // reached even though Sidebar rejected: log() ran, fallback shipped
// } catch (e) {
//   // reached ONLY for the Layout error
// }

Streaming an SVG document

namespaceURI tells React to serialise the tree with SVG rules rather than HTML rules, so self-closing tags and casing come out valid for an image/svg+xml response. bootstrapScripts is omitted because no React runs on the client for an image endpoint. allReady is awaited so the response body is one complete document — an SVG consumer cannot use a partially streamed document.

Example · jsx
import { renderToReadableStream } from 'react-dom/server';

function Badge({ label, value }) {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" width="140" height="20">
      <rect width="70" height="20" fill="#555" />
      <rect x="70" width="70" height="20" fill="#0a0" />
      <text x="8" y="14" fill="#fff" fontSize="11">
        {label}
      </text>
      <text x="78" y="14" fill="#fff" fontSize="11">
        {value}
      </text>
    </svg>
  );
}

export default async function handler() {
  const stream = await renderToReadableStream(
    <Badge label="coverage" value="98%" />,
    { namespaceURI: 'http://www.w3.org/2000/svg' }
  );
  await stream.allReady;
  return new Response(stream, {
    headers: { 'content-type': 'image/svg+xml' },
  });
}

Discussion

  • Be the first to comment on this lesson.