4 minutes
Streaming SSR with React: Turbocharging Load Times
Summary
React v18’s server-side rendering API now supports streaming HTML to clients in chunks as data resolves, dramatically cutting Time to First Byte (TTFB) and enabling progressive hydration. Using renderToPipeableStream
in Node.js or renderToReadableStream
in edge runtimes, you can send an initial shell immediately and stream remaining content through Suspense boundaries (react.dev, blog.logrocket.com).
1. Introduction
Traditional SSR with renderToString
or renderToNodeStream
waits for the full React tree and data before sending any HTML, delaying user-visible content. Streaming SSR flips that by dispatching a minimal HTML shell up front, then filling in suspended regions as data arrives, improving perceived performance and interactivity (medium.com).
2. React 18 Streaming APIs
renderToPipeableStream
(ReactDOMServer): For Node.js/Express, returns{ pipe, abort }
and callback hooks (onShellReady
,onAllReady
,onError
) to control when to pipe HTML to the response (medium.com).renderToReadableStream
(ReactDOMServer for web/edge): Returns a WHATWGReadableStream
suitable for fetch handlers in edge environments Gb deployments (react.dev).
2.1 Core Callbacks
onShellReady
: Fired when the shell (non-suspended parts) is ready, begin streaming.onAllReady
: Fired when all Suspense boundaries have resolved, optional final flush.onShellError
/onError
: Handle errors in shell or streaming, letting you render an error page or fallback.
3. Anatomy of a Streaming Response
- Initial Shell: HTML for static parts and any components not wrapped in Suspense.
- Suspense Placeholders: Markers (
<!--$!-->
) indicate where suspended content will flow in. - Chunked Payloads: When each Suspense boundary resolves, React streams its HTML chunk as a separate fragment wrapped in
<div data-rsc>
tags. - Client Hydration: React’s client runtime picks up streamed chunks, hydrates incrementally without waiting for the full bundle (github.com).
4. Setup & Code Examples
4.1 Express Server Example
import express from 'express';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
const app = express();
app.get('/', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<App />, {
onShellReady() {
res.status(didError ? 500 : 200);
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(err) {
didError = true;
console.error(err);
}
}
);
// Abort streaming after 5s to avoid hanging
setTimeout(abort, 5000);
});
app.listen(3000);
4.2 Edge Runtime Example
// For platforms like Cloudflare Workers
import { renderToReadableStream } from 'react-dom/server';
import App from './App';
export default async function handleRequest(request) {
const stream = await renderToReadableStream(<App />);
return new Response(stream, {
headers: { 'Content-Type': 'text/html' }
});
}
Wrap parts of <App>
in <Suspense>
with fallbacks (e.g., spinners or placeholders) to control streaming granularity.
5. Performance Metrics & Benchmarks
- TTFB: Streaming SSR often cuts TTFB by 50–80% compared to
renderToString
. - Time to Interactive (TTI): Progressive hydration allows key interactive components to become ready faster.
- Tools: Measure using Lighthouse, WebPageTest, or custom
wrk
benchmarks, comparingrenderToString
vs streaming (equalexperts.com).
6. Use Cases & Patterns
- Content-Heavy Pages: Blogs or news feeds with Suspense-wrapped lists, shell renders immediately, posts stream in.
- E-commerce: Product grids load skeleton placeholders, then real images stream as chunks.
- Dashboards: Metrics widgets show while data loads independently, enhancing perceived performance.
7. Pitfalls & Best Practices
- Boundary Granularity: Too coarse, delays dynamic parts; too fine, increases network chatter. Group related content in a boundary.
- Error Handling: Use
onShellError
to fallback render an error page. Also wrap Suspense with<ErrorBoundary>
. - Cache & CDN: Streamed responses may not cache easily at the edge, consider caching static shell separately or using surrogate controls.
8. Full Example Walkthrough
Build a blog index:
- App.jsx: Wrap
<PostList />
in<Suspense fallback={<SkeletonList />}>
. - Server: Use
renderToPipeableStream
as above. - Benchmark: Compare
renderToString
vs streaming withwrk -t4 -c100 -d10s localhost:3000
. - Observe: Initial HTML arrives immediately, followed by streamed
<li>
items.
9. Conclusion & Further Reading
Streaming SSR with React v18 transforms how you deliver HTML, making pages feel faster by sending a shell first and streaming data-driven parts. Combined with Suspense, this approach yields snappy UIs and progressive hydration.
Further Reading
- React Streaming SSR Docs: https://react.dev/reference/react-dom/server/renderToPipeableStream (react.dev)
- LogRocket’s Streaming SSR Guide: https://blog.logrocket.com/streaming-ssr-with-react-18/ (blog.logrocket.com)
- React 18 Release Blog: https://react.dev/blog/2022/03/29/react-v18 (react.dev)