Streaming SSR & RSC
Traditional server-side rendering blocks the response until every component has finished rendering, so a single slow data fetch delays the entire page. Streaming SSR fixes this by sending HTML to the browser in chunks as it becomes ready, letting the user see meaningful content almost immediately. Combined with Suspense, selective hydration, and React Server Components (RSC), streaming turns a slow all-or-nothing render into a progressive, interactive experience. This page explains how those pieces fit together conceptually.
Why streaming matters
In classic renderToString, the server builds the full HTML string in memory and returns it only when the slowest part of the tree resolves. Until then the browser sees nothing. Streaming flips this model: the shell renders first, and slower regions are flushed later over the same connection. The result is a faster Time to First Byte (TTFB) and First Contentful Paint, because the browser can start parsing markup and downloading assets while the server is still working.
| Approach | API | Behavior |
|---|---|---|
| Blocking SSR | renderToString | Returns one complete HTML string; blocks on slowest node |
| Streaming SSR (Node) | renderToPipeableStream | Pipes HTML chunks to a Node stream as they resolve |
| Streaming SSR (Edge/Web) | renderToReadableStream | Returns a Web ReadableStream for edge runtimes |
Streaming with renderToPipeableStream
On a Node server, renderToPipeableStream is the streaming entry point. It returns a controller with a pipe method and lifecycle callbacks. onShellReady fires once the non-suspended shell is renderable, which is the right moment to send the initial bytes and a 200 status.
import { renderToPipeableStream } from "react-dom/server";
import express from "express";
import App from "./App.jsx";
const app = express();
app.get("/", (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-Type", "text/html");
pipe(res);
},
onShellError() {
res.statusCode = 500;
res.setHeader("Content-Type", "text/html");
res.send("<h1>Something went wrong</h1>");
},
onError(error) {
didError = true;
console.error(error);
},
});
// Safety valve: abort if the render hangs.
setTimeout(abort, 10000);
});
app.listen(3000);
The key distinction is between the shell (everything outside Suspense boundaries) and deferred content (everything inside them). The shell streams first; suspended regions arrive later.
Suspense-driven streaming
<Suspense> is what makes streaming granular. Any subtree wrapped in a boundary can suspend while it waits for data, and React streams its fallback first, then patches in the real content when it resolves — all without client-side JavaScript driving the swap.
import { Suspense } from "react";
import Comments from "./Comments.jsx";
import Spinner from "./Spinner.jsx";
export default function App() {
return (
<html lang="en">
<body>
<h1>DevCraftly Blog</h1>
<article>Fast, static shell renders instantly.</article>
<Suspense fallback={<Spinner />}>
{/* Slow data fetch — streamed in when ready */}
<Comments />
</Suspense>
</body>
</html>
);
}
Behind the scenes, React emits the fallback inline, then later flushes a hidden chunk containing the resolved HTML plus a tiny inline script that moves it into place:
Output:
<!-- Sent immediately -->
<h1>DevCraftly Blog</h1>
<article>Fast, static shell renders instantly.</article>
<div hidden id="B:0"><!--$?--><template id="..."></template><!-- spinner --></div>
<!-- Flushed later, same response -->
<div hidden id="S:0"><ul><li>Great post!</li></ul></div>
<script>$RC("B:0","S:0")</script>
Place Suspense boundaries around genuinely slow or independent regions, not around every component. Too many boundaries fragment the stream and add overhead; too few mean a slow node blocks a large part of the page.
Selective hydration
Streaming changes hydration too. With hydrateRoot, React no longer needs the entire page before attaching event handlers. Each Suspense boundary hydrates independently and out of order, and React prioritizes the parts the user is actually interacting with.
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";
hydrateRoot(document, <App />);
If a user clicks a region that has streamed in but not yet hydrated, React captures the event, hydrates that boundary first, and replays the interaction. This selective hydration means a slow widget at the bottom of the page never blocks interactivity at the top.
How RSC streams server output
React Server Components push streaming a step further. Instead of streaming HTML, RSC streams a serialized description of the rendered tree — the RSC payload — produced by a renderer such as renderToReadableStream from react-server-dom-webpack/server. Server Components run only on the server: they can touch databases and secrets, ship zero JavaScript to the client, and reference Client Components by module so the client knows what to hydrate.
The payload is a compact, line-delimited format that the client reconstructs into React elements as chunks arrive. Client Components appear as references that React resolves and hydrates, while everything else stays server-only.
Output:
0:["$","main",null,{"children":["$","$L1",null,{}]}]
1:I["./LikeButton.jsx",["client.js"],"default"]
Here line 0 is the server-rendered tree and line 1 is a lazy reference to an interactive Client Component. Frameworks like Next.js (App Router) layer Suspense, streaming HTML, and the RSC payload together so the first request delivers HTML for fast paint while the RSC stream drives hydration and subsequent navigation.
Best practices
- Use
renderToPipeableStream(Node) orrenderToReadableStream(edge/Web) instead ofrenderToStringfor any non-trivial app. - Wrap independent, data-dependent regions in
<Suspense>so a slow fetch never blocks the shell. - Keep the shell light: render layout, navigation, and above-the-fold content outside Suspense for the fastest first paint.
- Always handle
onShellErrorandonError, and set a render timeout viaabortso a hung request cannot stall the connection. - Pass
bootstrapScriptsso React can begin hydration as soon as the shell arrives. - Prefer Server Components for data-heavy, non-interactive UI to ship less JavaScript, and reserve Client Components for genuine interactivity.