Streaming SSR with renderToPipeableStream in Vite
Buffered SSR waits for the entire tree to render before sending a byte, so a slow data dependency stalls the whole page. React’s renderToPipeableStream flushes the shell immediately and streams Suspense boundaries as they resolve — but wiring it into Vite’s SSR middleware takes care with onShellReady and the module graph. This guide does that. It extends Vite SSR and SSG integration; the parent overview is Vite Configuration Ecosystem.
The problem exists because renderToString is synchronous and total. It walks the whole element tree in one pass, and any component that reads data through Suspense either has that data available synchronously or throws a promise that renderToString cannot await — so the classic buffered server first resolves every query, then renders, then writes. The consequence is that time-to-first-byte is pinned to the slowest dependency in the tree. A page that is 90% static chrome and 10% a slow product-recommendations widget sends nothing for the full duration of that widget’s query. The browser has no markup to parse, no stylesheet to fetch, no fonts to warm; the whole critical-path is stalled behind one await.
Streaming breaks that coupling. renderToPipeableStream renders the shell — everything outside a <Suspense> boundary — and calls onShellReady the moment that shell is renderable, without waiting for any boundary’s data. You pipe the shell to the client immediately, the browser starts parsing and painting, and React continues rendering the suspended boundaries in the background. As each boundary resolves, React emits an inline <template> chunk plus a tiny script that swaps the fallback for real content in place. In the Vite pipeline this sits at the very end of the request path: Vite’s dev server (in middleware mode) still owns transform, HMR, and module resolution, but the response body is no longer a string you res.send — it is a Node stream you pipe. That single change is what most of the gotchas below are really about.
Prerequisites & reproducible setup
# Vite 5.4.x, React 18, Node 20+ (Express SSR server)
npm create vite@latest ssr-stream-demo -- --template react-ts
cd ssr-stream-demo && npm install express
# A component with a Suspense boundary around a slow data read.
You need a custom SSR server (Express) using Vite in middleware mode, React 18’s renderToPipeableStream, and a component tree with at least one <Suspense> boundary so there is something to stream after the shell.
appType: 'custom' is load-bearing here. The default appType: 'spa' installs Vite’s own HTML-serving and history-fallback middleware, which will happily answer your route with index.html before your SSR handler ever runs. Setting it to custom disables that fallback so your app.use('*', …) handler is the thing that produces HTML. middlewareMode: true tells Vite not to open its own HTTP listener; instead it hands you vite.middlewares, a connect stack you mount inside Express so that requests for /@vite/client, /@react-refresh, transformed source modules, and HMR websocket upgrades are handled before your catch-all. Get that ordering wrong and either HMR breaks or your handler never sees the request.
The Suspense boundary is not optional decoration. The shell, by definition, is the subtree that renders without suspending; the streamed chunks are exactly the boundaries. If your tree has no <Suspense>, onShellReady fires only once the entire tree is renderable, which is behaviorally identical to buffering — you have paid for a stream and received a string. Put the boundary around the genuinely slow read (a database query, a fetch to an upstream service) and give it a fallback that has stable dimensions, so the shell paint does not shift when the real content swaps in.
Diagnosis workflow
- Confirm the buffering stall. With
renderToString, the response arrives only after the slow read resolves; the browser shows nothing until then. Reproduce it deliberately before you fix it: wrap the slow read in an artificialawait new Promise(r => setTimeout(r, 3000))and load the page with the network panel open. You should see time-to-first-byte sitting at roughly three seconds with zero bytes received in the meantime. That number is your baseline — after wiring the stream, the shell’s first byte should land in single-digit milliseconds while the boundary chunk still arrives around the three-second mark. If you cannot see the stall, you have no proof the fix did anything. - Identify the shell. The shell is everything outside
<Suspense>; it must render without awaiting slow data so it can flush immediately. Audit the tree top-down and confirm nothing above or beside the boundary reads suspending data — a layout component that fetches the current user, a theme provider that awaits a config call, or a top-level route loader will all pull their suspension up past the boundary and delayonShellReady. The rule of thumb: if a component can render a meaningful frame with only synchronous props, it belongs in the shell; if it must wait for I/O, it belongs inside a boundary with a fallback. - Choose the flush callback.
onShellReadyfires when the shell is renderable (send it now);onAllReadyfires only when everything resolved (use for crawlers/SSG, not for streaming). The distinction is not cosmetic: piping fromonShellReadyproduces a chunked response whose first bytes are the shell, while piping fromonAllReadyproduces a response that is byte-for-byte identical to buffered SSR but arrives all at once at the end. React lets you decide per-request, so a single handler can stream for humans and buffer for bots by choosing which callback opens the pipe. Do not set both; whichever one you pipe from is the one that defines the response’s timing.
Annotated solution config
// server.js — Express + Vite middleware, streaming SSR (React 18, Node 20+).
import express from 'express';
import { createServer as createViteServer } from 'vite';
const app = express();
const vite = await createViteServer({ server: { middlewareMode: true }, appType: 'custom' });
app.use(vite.middlewares);
app.use('*', async (req, res) => {
try {
// Load the SSR entry through Vite so HMR and transforms apply.
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx');
render(req.originalUrl, {
onShellReady() {
// Shell is renderable — send headers and start streaming NOW.
res.status(200).set('Content-Type', 'text/html');
res.write('<!doctype html><div id="root">');
pipe(res); // pipe the React stream into the response
},
onShellError() {
res.status(500).send('shell error');
},
});
} catch (e) {
vite.ssrFixStacktrace(e);
res.status(500).end(e.message);
}
});
// src/entry-server.tsx — the render fn using renderToPipeableStream.
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
export function render(url: string, callbacks: {
onShellReady(): void; onShellError(): void;
}) {
const { pipe } = renderToPipeableStream(<App url={url} />, {
onShellReady: callbacks.onShellReady,
onShellError: callbacks.onShellError,
});
return pipe; // caller pipes into res after onShellReady
}
Two details in this wiring decide whether streaming actually works. First, vite.ssrLoadModule('/src/entry-server.tsx') loads the entry through Vite’s transform pipeline rather than Node’s own import, so JSX, TypeScript, path aliases, and — critically in dev — HMR invalidation all apply. If you import the entry directly, edits to App.tsx will not be reflected without a server restart, and any non-JS syntax will throw at load time. Second, the handler writes the opening <!doctype html><div id="root"> itself and only then calls pipe(res). React’s stream contains the contents of the root, not the surrounding document, so you are responsible for the head, the opening body, and — after the stream ends — the closing tags. The order is fixed: doctype and open tags, then the React body, then your closing markup, because once the first byte is written you can no longer change status or headers.
It helps to see the tree that produces something to stream. The boundary below wraps a component that reads slow data; everything else is shell and flushes with onShellReady:
// src/App.tsx — a shell plus one Suspense boundary (React 18).
import { Suspense } from 'react';
import SlowRecommendations from './SlowRecommendations';
export default function App({ url }: { url: string }) {
return (
<html lang="en">
<body>
{/* Shell: renders synchronously, flushes immediately. */}
<header><h1>Catalog</h1></header>
<nav>{/* static links */}</nav>
{/* Streamed: the fallback ships in the shell, real HTML later. */}
<Suspense fallback={<p style={{ minHeight: 120 }}>Loading picks…</p>}>
<SlowRecommendations url={url} />
</Suspense>
<footer>© 2026</footer>
</body>
</html>
);
}
The minHeight on the fallback matters: it reserves the vertical space the real content will occupy, so the swap at resolution time does not push the footer down and trigger a layout shift. This is the SSR-side equivalent of sizing an image before it loads.
onShellReady — write the head first, then stream the React body.How it works under the hood
React’s fizz renderer walks the tree depth-first. When it reaches a component that reads suspending data, that component throws a promise; fizz catches it at the nearest <Suspense> boundary, records the boundary as pending, and emits the boundary’s fallback markup inline at that position. Rendering then continues past the boundary, so the rest of the shell completes. Once the shell has no more pending work outside boundaries, fizz calls onShellReady. Everything up to this point is synchronous CPU work; no I/O has been awaited by React itself — the promises are the components’ own.
When a boundary’s promise resolves, fizz renders that subtree into a hidden <template> and writes two things to the stream: the template containing the real HTML, and a small inline script ($RC(...)) that moves the template’s children into the boundary’s position and removes the fallback. This is why the client needs no framework code to see progressive content — the swap is driven by tiny scripts React injects into the byte stream. The scripts are inert by design: they run, mutate the DOM, and are done, all before React’s client bundle hydrates. Hydration then attaches to a DOM that already matches, boundary by boundary, in the order boundaries resolved rather than the order they appear in the document.
Ordering has a consequence for correctness. Because boundaries stream in resolution order, a boundary near the top of the page can arrive after one near the bottom. React handles the visual placement, but any assumption in your code that content arrives top-to-bottom is wrong under streaming. The stream also holds the response open until the last boundary resolves or errors; a boundary whose promise never settles will keep the connection open indefinitely, which is why a server-side render timeout (renderToPipeableStream accepts no timeout itself, so you wrap it) is not optional in production.
Vite’s role in all of this is confined to the transform and module layer. It does not touch the stream. In dev, ssrLoadModule compiles entry-server.tsx and its imports on demand, tracks them in the SSR module graph, and invalidates them on edit so the next request re-renders with fresh code. In a production build you replace ssrLoadModule with a static import of the built entry-server.js, and Vite is out of the request path entirely — the streaming mechanics are unchanged because they were always React’s, not Vite’s.
Verification
# Node 20+ — start the server and observe progressive HTML.
node server.js
# curl shows the shell arriving before the slow boundary:
curl -N http://localhost:3000/ | head -c 200 # shell bytes appear immediately
With curl -N (no buffering), the shell HTML arrives immediately and the Suspense content follows as a later chunk. In the browser, the layout paints while the slow section shows its fallback, then hydrates in place — time-to-first-byte no longer waits on the slow read.
The -N flag is the whole test. By default curl buffers received data and prints it in one block, which would hide the two-arrival signature entirely; -N disables that so you see bytes as they land. Two confirmations are worth capturing. First, timing: run curl -N -w '\nttfb=%{time_starttransfer}s total=%{time_total}s\n' -o /dev/null http://localhost:3000/ and check that time_starttransfer is small (the shell) while time_total is close to your artificial delay (the boundary). A large gap between them is the streaming signature expressed as numbers. Second, content: pipe the raw stream through grep for the $RC script name or the fallback text and confirm the fallback markup appears in the early bytes and the real content appears in a later chunk. If both arrive together, something downstream is buffering — jump to the gotchas.
For a stricter check that survives CI, assert on the shape of the response rather than eyeballing it:
# Node 20+ — fail if shell and boundary arrive in the same chunk.
node server.js & # background the SSR server
sleep 1
# Record the timestamp of the first byte vs. the boundary marker.
curl -N -s http://localhost:3000/ \
| grep -n -m1 -e 'id="root"' -e 'Loading picks' \
&& echo "shell chunk observed before boundary resolves"
curl -N is the streaming signature.Gotchas & edge cases
- Buffering proxies. An nginx/CDN that buffers responses collapses streaming back to buffered; disable response buffering on the SSR route. The symptom is that
curl -Nagainst the app server shows progressive chunks but the same request through the proxy shows one late block. The root cause is nginx’sproxy_buffering ondefault, which accumulates the upstream response before forwarding. The fix isproxy_buffering off(or theX-Accel-Buffering: noresponse header) scoped to the SSR location, plusproxy_http_version 1.1so chunked transfer is used. Confirm by re-running the timing curl through the proxy and checking thattime_starttransfermatches the direct measurement. - Headers after the first write. Set status and
Content-TypeinsideonShellReady, beforeres.write; after the first byte they throw. Node marks headers as sent on the first body byte, and any laterres.status()orres.set()raisesERR_HTTP_HEADERS_SENT, which — if it fires inside a stream callback — can crash the process rather than return a clean 500. The fix is discipline about ordering: all header mutation happens inonShellReadybeforepipe, and error handling for boundaries lives inonError/error boundaries, not in a late header write. Confirm by forcing a boundary error and verifying the response still closes cleanly with the shell intact. - Crawlers. Bots that do not execute JS need complete HTML; branch to
onAllReadyfor known crawler user-agents. The$RCswap scripts never run for a non-executing client, so a streamed response leaves such a bot looking at fallbacks forever. The fix is to sniff the user-agent and, for known crawlers, pipe fromonAllReadyinstead — same render, complete HTML, no reliance on client script. Confirm withcurl -A 'Googlebot' -Nand check that the real content, not the fallback, is present in the payload. Weigh the cost:onAllReadyreintroduces the slowest-dependency wait for those requests, which is acceptable for bots but never for humans. - Boundary errors. Wrap slow sections in an error boundary so a failed read renders a fallback instead of aborting the whole stream. Without one, a rejected promise inside a boundary propagates to
onErrorand can tear down the response mid-stream, leaving the browser with a truncated document it cannot recover. A client-side error boundary around the suspended component lets React render an error fallback for just that boundary while the rest of the page stands. Confirm by making the slow read reject and verifying the shell and sibling boundaries still deliver, with only the failed region showing its fallback.
Performance considerations
Streaming improves perceived and measured load time, but not uniformly. Time-to-first-byte and First Contentful Paint improve dramatically because the shell paints without waiting for data. Largest Contentful Paint improves only if the LCP element is in the shell; if your hero content is itself behind a slow boundary, streaming moves its arrival earlier than buffered SSR would but it still waits on the data. The metric that can regress is total data transferred: each boundary adds its <template> wrapper and swap script, so a page split into dozens of tiny boundaries ships measurably more bytes than one buffered render. Boundary granularity is the tuning knob — wrap the genuinely slow, genuinely independent regions, not every leaf.
There is also a back-pressure dimension. pipe(res) respects the socket’s writable state, so a slow client naturally throttles React’s writes; you do not need to manage this manually. But a boundary that resolves while the socket is congested will queue, and if you have set an aggressive server timeout you can abort a response that was merely slow to drain rather than slow to compute. Measure time_starttransfer and time_total separately (as in the verification step) so you can tell a slow render from a slow flush.
When not to use this
Streaming is the wrong tool when the whole page is fast. If no component reads slow data, there is no boundary, onShellReady and onAllReady fire at the same moment, and you have added stream plumbing and error-handling surface for no latency win — a plain renderToString (or, in production, a cached buffered render) is simpler and identical in timing. It is also the wrong tool for static generation: SSG wants the complete, final HTML written to disk, which is precisely onAllReady semantics, so build a static renderer on renderToStaticMarkup or onAllReady and skip the streaming path. Finally, weigh it against edge constraints — some CDN and serverless response models do not support chunked streaming responses, and on those platforms a streamed handler silently buffers, giving you the byte overhead of boundaries with none of the latency benefit. Confirm the platform forwards chunked responses before committing to streaming there.
Related
- Vite SSR and SSG integration — the parent cluster on SSR wiring.
- Configuring Vite SSR with Express and Node.js — the middleware-mode server this builds on.
- Fixing hydration mismatch errors in Vite SSR — the hydration concerns streaming shares.