Streaming SSR
What you'll learn
- The all-or-nothing problem with classic
renderToString - How
renderToPipeableStream/renderToReadableStreamflush the shell first - The trick: hidden templates plus tiny inline scripts that swap content in
- Why inline scripts need zero framework JavaScript
- Ordering guarantees, what must flush first, what can arrive out of order
- How server errors degrade a boundary to client rendering, and the full capstone picture
Chapter 1 sold you SSR: run components on the server, send HTML, paint early. But a catch hides in the naive version. If any part of the page needs slow data, recommendations, comments, the server must wait for all of it before sending any HTML. The slowest query on the page decides when the user sees anything. Streaming SSR removes that bottleneck without giving up anything you learned in chapters 1 and 2.
The problem: the slowest query wins
renderToString(<App />), the original server API, is beautifully simple and brutally all-or-nothing:
What happens:
- Render the whole tree. Components that need data must finish fetching, with Suspense, the server waits for every boundary to resolve.
- Concatenate one complete HTML string.
- Send it. Only now does the browser start parsing, painting, or downloading your bundle.
One three-second recommendations widget means three extra seconds of blank page, even though the header, article, and footer were ready in 50 milliseconds. And worse: the bundle download can't even start until the HTML arrives, because the <script> tags live in that HTML.
The fix: flush the shell, stream the rest
The streaming APIs, renderToPipeableStream in Node, renderToReadableStream for web/edge runtimes, flip the order:
Jargon: "streaming SSR". Server rendering that sends HTML in chunks as it becomes ready: the shell (everything outside Suspense boundaries, with fallbacks inlined) flushes immediately, and each boundary's real content streams behind it as it resolves.
import { Suspense } from 'react';
import { Comments } from './Comments';
import { Post } from './Post';
import { Spinner } from './Spinner';
export default function PostPage() {
return (
<main>
<Post /> {/* fast — part of the shell */}
<Suspense fallback={<Spinner />}>
<Comments /> {/* slow — streams in later */}
</Suspense>
</main>
);
}
What happens:
- The server renders everything it can without waiting:
<main>,<Post />, and, becauseCommentssuspends on its slow data, the boundary's fallback, the spinner, inlined in the HTML right where the content will eventually go. - That shell HTML flushes immediately. The browser parses it, paints the post with a spinner where comments will be, and starts downloading the JavaScript bundle, the
<script>tags are in the shell! Seconds of parallel progress replace seconds of waiting. - Meanwhile the server keeps working on
Comments. When its data resolves, the server renders the boundary's real HTML, but the spot where it belongs shipped long ago. So it streams two things further down the document: the real content inside a hidden<template>(inert and invisible), plus a tiny inline<script>that finds the fallback, swaps the template's content into its place, and marks the boundary complete. - That script runs the instant the HTML parser reaches it, long before your framework bundle loads. The spinner quietly becomes the comments section.
Pseudocode model, not real source:
<!-- Conceptual sketch of what the stream contains --><!-- Chunk 1: the shell, flushed immediately --><main><article>…the post…</article><!-- boundary marker: pending --><div id="B1">…spinner fallback…</div></main><script src="/bundle.js"></script><!-- …time passes while the server waits on data… --><!-- Chunk 2: the boundary resolves --><template id="T1"><div>…real comments…</div></template><script>// tiny inline script: move T1's content into B1's spot// and mark boundary B1 complete</script>
Step 4 is the clever part, so say it plainly: the page assembles itself with no framework code at all. Inline scripts execute during HTML parsing. By the time React's bundle has even downloaded, the user may already see a complete page. And "the page appears progressively" isn't a mode you opt into per component, it's just what HTML streaming plus Suspense declarations produce.
Ordering guarantees
Two promises make this predictable:
- The shell always completes first. Everything outside boundaries, plus every fallback, is in the first flush. There's always a coherent page from the very first chunk.
- Boundaries flush as they finish, in any order. Comments before recommendations, recommendations before related posts, whatever resolves first streams first. Each swap is independent; no boundary waits for a sibling.
Inside one boundary, of course, its content is complete when it streams. Partial UI never appears.
When a boundary fails on the server
If Comments's data fetch rejects instead of resolving, the server can't send real content, but the show goes on:
What happens:
- The fallback stays on screen, the shell already shipped it.
- The server streams a different tiny script, one that marks this boundary errored instead of complete.
- When React hydrates, it reads that mark and doesn't try to adopt server DOM for the region. It client-renders just that island, refetching or recomputing in the browser, inside its own error/Suspense handling.
- Every other boundary hydrates normally. One failure, one island rebuilt, nothing else touched.
This connects directly to chapter 2's boundary states: streamed-and-present, still-streaming, errored-on-server. Streaming SSR is the machinery that creates those states; selective hydration is the machinery that consumes them.
The capstone: the whole server story in one timeline
You now have every piece. Watch them interlock:
- Request arrives. The server starts rendering.
- Shell HTML flushes instantly, post visible, fallbacks standing in for pending boundaries, bundle download begins immediately.
- Hydration starts early on the shell: header and post become interactive while comments are still streaming.
- Boundary content streams as hidden templates plus inline scripts; each swap lands the moment the HTML parser reaches it, no framework JS required.
- Each island hydrates as it's ready, its HTML present, its JS downloaded. Comments become interactive independently of recommendations.
- Early clicks and hovers are never lost: clicks prioritize synchronously hydrating exactly that island; hovers are recorded and replayed.
- A failed boundary degrades gracefully to client rendering, one island, not the page.
Each stage made the next one possible: Suspense seams → streamable chunks → self-assembling HTML → incremental hydration → input that follows the user's attention.
The three server flavors, compared
renderToString | Streaming SSR | RSC (with streaming SSR) | |
|---|---|---|---|
| What the server sends | One complete HTML string | HTML chunks: shell, then boundary swaps | Serialized UI rows; usually also streamed HTML for first paint |
| Slow data blocks first paint? | Yes, everything waits | No, fallbacks hold the spot | No, placeholders hold the spot |
| Component code shipped? | All of it | All of it | Only client components |
| Data fetching | Your own APIs and effects | Same | Server components await data directly |
| Hydration | Whole tree, one pass | Incremental, per boundary | Client islands hydrate; server parts never ship |
| Who provides it | React itself | React itself | Frameworks, they compose RSC output with streaming SSR |
Read the columns left to right and you can see the decade-long direction of React on the server: send something sooner, send less JavaScript, wait for nothing you don't have to.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Streaming means the browser renders half-components. Reality: the shell is complete, and each boundary streams complete; fallbacks hold the in-between spots. Nothing partial ever paints.
- Misconception: The progressive swaps need React's JavaScript. Reality: tiny inline scripts perform the swaps during HTML parsing, before, and independent of, your bundle.
- Misconception: Boundaries stream in tree order. Reality: they flush in resolution order, whichever data arrives first streams first.
- Misconception: A server error in one boundary blanks the page. Reality: the shell and other boundaries are unaffected; the failed island is marked and client-rendered.
- Misconception: Streaming SSR requires React Server Components. Reality: it works with the classic all-client component tree, Suspense boundaries are the only requirement. RSC is a separate, composable layer.
- Misconception:
renderToStringis useless now. Reality: it's still the simplest correct tool when pages are small, data is uniformly fast, or you're generating static HTML, its cost only shows when data is slow.
Why it works this way
- HTML is already a streaming format. Browsers have parsed and painted partial documents since the 1990s. Streaming SSR finally uses that property instead of buffering against it.
- Fallbacks are placeholders the design already had. Suspense declared "show this until ready"; streaming takes that declaration literally across a network boundary.
- Inline scripts are the only zero-dependency execution channel. Swap logic inside the bundle would create a chicken-and-egg wait; code embedded in the HTML itself runs for free, in order, during parsing.
- Failures degrade at the same seam they stream at. Because the boundary is the unit of streaming, it's also the unit of failure, the blast radius of any slowness or error is exactly one island.
Try it yourself
- In a page with a deliberately slow Suspense boundary (a 3-second artificial delay), view-source immediately on load. Expected: the shell and fallback are already in the HTML. Watch the document response in Network stay open and append the template-and-script chunk when the delay ends.
- Disable external JavaScript in DevTools and reload that page. Expected: the shell and the progressive swaps still appear, the inline swap scripts run during parsing, before and without your bundle.
- Make the boundary's data fetch reject. Expected: shell and siblings render fine; the failed region appears only after the bundle runs (client-rendered), and a server log or dev overlay reports the boundary error.
- Give two boundaries different artificial delays (1s and 3s) and reverse their order in the JSX. Expected: they still pop in at 1s and 3s, resolution order, not tree order.
Recap
- Without streaming, the slowest data on the page delays all HTML. Streaming flushes the shell, everything outside Suspense, fallbacks inlined, immediately.
- When a boundary resolves, its HTML streams as a hidden template plus a tiny inline script that swaps it into place, executing during HTML parsing, no framework JS needed.
- Guarantees: the shell completes before anything flushes; boundaries flush in resolution order, each one complete.
- A server-side boundary error streams an "errored" mark instead; hydration client-renders just that island.
- Full picture: request → instant shell → progressive swaps → early hydration → islands hydrate as ready → early events prioritized and replayed.
- The three flavors:
renderToString(all-or-nothing), streaming SSR (progressive HTML + hydration), RSC (UI as streamed data; frameworks compose it with streaming SSR).