react-dom/server exposes both renderToPipeableStream and renderToReadableStream. How do the two differ, and how do you choose between them?
answer
- two stream ecosystems, one renderer
- Node writable versus Response body
- callbacks versus an awaited promise
- abort() versus a signal option
- runtime picks it, not preference
basics
~20 sThey produce the same streamed HTML for different runtimes. renderToPipeableStream targets Node.js streams: it returns { pipe, abort } synchronously and you pipe into the server response. renderToReadableStream targets Web Streams: it returns a promise for a ReadableStream you pass to a Response.
solid answer
~40 sThe rendering behaviour is the same — shell first, Suspense boundaries streamed in as they resolve. What differs is the stream type and therefore the runtime. `renderToPipeableStream` is the Node.js API: it returns `{ pipe, abort }` synchronously, and you call `pipe(res)` on a Node writable, typically inside the `onShellReady` callback. `renderToReadableStream` is the Web Streams API used by edge runtimes, Deno, Bun and Cloudflare Workers: it returns a promise that resolves once the shell is ready, giving you a `ReadableStream` you hand straight to `new Response(stream, { headers })`. Its error surface is shaped accordingly — the promise rejects instead of firing an `onShellError` callback, and you cancel by passing an `AbortController`'s signal in the `signal` option rather than calling a returned `abort`. Pick by where the code runs, not by preference.
code
javascript · 27 linesimport { renderToReadableStream } from 'react-dom/server';
export default async function handler(request) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
signal: controller.signal,
onError(error) {
console.error(error);
},
});
stream.allReady.then(() => clearTimeout(timer));
return new Response(stream, {
status: 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
clearTimeout(timer);
return new Response('<h1>Something went wrong</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}go deeper
Know that both stream server-rendered HTML and that the choice follows the runtime: the pipeable one for Node servers, the readable one for edge and other Web-Streams environments.
Explain the shape differences you would actually type — a synchronous return with onShellReady callbacks versus an awaited promise resolving to a stream you pass to Response, and abort() versus the signal option.
Show how you would keep an SSR layer portable: shared tree and options, a thin per-runtime adapter for the entry point and response, and error handling mapped onto each API's surface.
Own the runtime decision itself — what an edge deployment costs you in data access and observability versus the latency it buys — and make sure the rendering layer is not what locks the platform choice in.
## Same renderer, two stream shapes React's streaming server renderer is one implementation with two entry points, and the split exists for a reason that has nothing to do with React: JavaScript has two incompatible stream ecosystems. - **Node.js streams** — `Writable`, `Readable`, `pipe()`. This is what an Express or Fastify handler's `res` object is. - **Web Streams** — `ReadableStream`, `WritableStream`, the standard the platform settled on. This is what `Response` accepts in browsers, Deno, Bun, Cloudflare Workers and other edge runtimes. `renderToPipeableStream` speaks the first dialect, `renderToReadableStream` the second. Both emit byte-identical HTML: the shell, then each Suspense boundary's content as it resolves, with the inline scripts that swap out fallbacks. ## The Node.js API ```js import { renderToPipeableStream } from 'react-dom/server'; const { pipe, abort } = renderToPipeableStream(<App />, { bootstrapScripts: ['/main.js'], onShellReady() { res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); pipe(res); }, onShellError(error) { res.statusCode = 500; res.setHeader('Content-Type', 'text/html'); res.send('<h1>Something went wrong</h1>'); }, onError(error) { console.error(error); }, }); ``` It returns **synchronously**, before anything has rendered, and reports progress through callbacks: `onShellReady`, `onShellError`, `onAllReady`, `onError`. You never see a stream object — you push into the response by calling `pipe`. Cancellation is the `abort` function on the returned object. ## The Web Streams API ```js import { renderToReadableStream } from 'react-dom/server'; export default async function handler(request) { const controller = new AbortController(); try { const stream = await renderToReadableStream(<App />, { bootstrapScripts: ['/main.js'], signal: controller.signal, onError(error) { console.error(error); }, }); return new Response(stream, { status: 200, headers: { 'content-type': 'text/html' }, }); } catch (error) { return new Response('<h1>Something went wrong</h1>', { status: 500, headers: { 'content-type': 'text/html' }, }); } } ``` It returns a **promise**. That promise resolves when the shell is ready — the awaited value plays the role `onShellReady` plays in the Node API — and rejects if the shell itself fails, which is why shell errors are a `try/catch` here rather than a callback. The resolved `ReadableStream` also carries an `allReady` promise that settles when every boundary has finished, the counterpart of `onAllReady`. Cancellation follows the platform convention: pass `signal` from an `AbortController` and call `controller.abort()`. ## Choosing The choice is made by the runtime, not by taste: - Running on Node.js with a framework whose response object is a Node writable → `renderToPipeableStream`. - Running on an edge runtime, Deno, Bun, a Worker, or anywhere the handler returns a `Response` → `renderToReadableStream`. Modern Node can construct a `Response` too, and adapters exist to bridge the two worlds, but reaching for a bridge to use the "other" renderer buys nothing: the output is the same and each API is shaped for its host. ## What this means for portable code If a codebase must run in both environments, keep the React tree and the rendering *options* — `bootstrapScripts`, `onError`, `nonce`, `identifierPrefix` — in shared code, and isolate the entry-point call plus the response construction in a per-runtime adapter. That is exactly how frameworks structure their SSR layer, and it is a good answer when an interviewer asks how you would support both. A related note for a Node deployment: React 19 removed `renderToNodeStream`, the old Node streaming entry point that React 18 had already deprecated, so if you meet a codebase still calling it, `renderToPipeableStream` is the migration target. ## Answering it Lead with "same HTML, different stream type, therefore different runtime". Then give one concrete asymmetry each way — synchronous return plus callbacks versus an awaited promise; `abort()` versus a `signal` option — and finish with the selection rule. Candidates who describe one as "newer" or "faster" have missed the point.
- In the Web Streams renderer, what plays the role that onShellReady plays in the Node one?The returned promise itself. `renderToReadableStream` resolves once the shell has rendered, so awaiting it is the signal that you can construct the `Response`. A shell failure rejects that promise instead of invoking a separate callback, which is why the error path is a `try/catch` around the await.
- How would you structure a codebase that must render on both Node and an edge runtime?Keep the component tree and the shared render options — bootstrap scripts, `onError`, `nonce` — in runtime-neutral modules, and isolate only the entry-point call and response construction behind a thin per-runtime adapter. Everything React-specific stays shared; only the twenty lines that touch the host's stream and response types are duplicated.
- Does the choice of renderer change the HTML the browser receives?No. Both emit the same streamed markup: the shell, then each resolved Suspense boundary's content plus the small inline script that swaps it into place. The client-side code, including hydration, is identical. Only the server-side plumbing — stream type, error surface, cancellation mechanism — differs.
saying these in an interview costs you the question
- Says one renderer is newer or faster than the other
- Thinks renderToPipeableStream returns a promise
- Believes the two produce different HTML for the client
- Claims you can pipe a ReadableStream into a Node response directly
- Picks the API by preference rather than by runtime