Streaming SSR — Streaming HTML to the Browser for Faster Load Times
In this tutorial, you will learn about Streaming SSR. We cover key concepts, practical examples, and best practices to help you master this topic.
Streaming SSR sends HTML to the browser in chunks as it renders, enabling faster Time to First Byte and progressive content display before the full page rendering completes.
What You'll Learn
By the end of this tutorial, you will understand what streaming SSR is, how it differs from traditional SSR, how React 18 renderToPipeableStream works, how to use Suspense boundaries to define streaming sections, and how streaming improves Core Web Vitals.
Why It Matters
Traditional SSR has a major limitation: the server must finish rendering the entire page before sending anything to the browser. For pages with slow data fetching (API calls, database queries), the user sees nothing until everything is ready. Streaming SSR sends the HTML shell immediately and streams content as it becomes available, dramatically improving perceived performance.
Real-World Use
A travel booking site used streaming SSR for search results. The page shell (header, search form, filters) streams instantly. Search results stream in as the database query returns data. Time to First Byte dropped from 1200ms to 150ms. Users started interacting with filters while results were still loading.
Streaming SSR vs Traditional SSR
┌──────────────────────────────────────────────────────────┐
│ Traditional SSR vs Streaming SSR │
├──────────────────────────────────────────────────────────┤
│ │
│ Traditional SSR: │
│ Server: Render entire page → Send full HTML │
│ Time: |------ render all data ------| │
│ |----------- send HTML ---------| │
│ User: [sees page] │
│ │
│ Streaming SSR: │
│ Server: Send shell → Stream content as ready │
│ Time: |-- shell --|-- content 1 --|-- content 2 --| │
│ User: [shell] [content 1] [content 2] │
│ │
│ Benefits: │
│ • Shell loads immediately (header, nav, skeleton) │
│ • Fast content streams first (text, images) │
│ • Slow content streams last (charts, widgets) │
│ • User sees progress instead of blank screen │
│ │
└──────────────────────────────────────────────────────────┘
Think of streaming SSR like downloading a movie versus waiting for the entire download. Traditional SSR is like waiting for the whole movie to download before watching. Streaming SSR is like streaming — the video player starts showing content immediately while the rest buffers in the background. You see the opening scene while later scenes are still downloading.
React 18 renderToPipeableStream
import { renderToPipeableStream } from 'react-dom/server';
import { App } from './App';
import express from 'express';
const app = express();
app.get('*', (req, res) => {
// Set the correct content type for streaming
res.setHeader('Content-Type', 'text/html');
// Stream the HTML shell immediately
res.write(`
<!DOCTYPE html>
<html>
<head>
<title>Streaming SSR App</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="root">
`);
// Create the stream
const { pipe, abort } = renderToPipeableStream(
React.createElement(App),
{
// Called when the shell is ready to stream
onShellReady() {
pipe(res);
},
// Called when an error occurs during streaming
onError(error) {
console.error('Streaming error:', error);
// Abort streaming and send fallback
abort();
res.status(500).send('An error occurred');
},
// Called when all content has streamed
onAllReady() {
console.log('All content streamed');
}
}
);
// Close the HTML after streaming
res.on('close', () => {
res.write('</div></body></html>');
res.end();
});
});
Suspense Boundaries with Streaming
import { Suspense, lazy } from 'react';
// Slow components wrapped in Suspense
// They stream independently
const ProductDetails = lazy(() => import('./ProductDetails'));
const CustomerReviews = lazy(() => import('./CustomerReviews'));
const RelatedProducts = lazy(() => import('./RelatedProducts'));
function ProductPage({ productId }) {
return (
<div>
{/* Fast content — renders immediately in shell */}
<header>
<h1>Product Catalog</h1>
<nav>...</nav>
</header>
<main>
{/* Content that needs data fetching */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={productId} />
</Suspense>
{/* Each Suspense boundary streams independently */}
<Suspense fallback={<ReviewsSkeleton />}>
<CustomerReviews productId={productId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={productId} />
</Suspense>
</main>
<footer>...</footer>
</div>
);
}
// Server output order:
// 1. Shell: <header>, <main>, Suspense fallbacks
// 2. ProductDetails streams (fast API call)
// 3. CustomerReviews streams (slower — needs review aggregation)
// 4. RelatedProducts streams (slowest — complex query)
// 5. Closing HTML
Streaming with Timeout and Fallback
import { renderToPipeableStream } from 'react-dom/server';
function streamPage(req, res, component, timeoutMs = 10000) {
let timeoutId = setTimeout(() => {
// If shell is not ready in time, abort and fall back
abort();
res.status(500).send(`
<!DOCTYPE html>
<html>
<body>
<h1>Page is taking longer than expected</h1>
<p>Please refresh and try again.</p>
<script>location.reload()</script>
</body>
</html>
`);
}, timeoutMs);
const { pipe, abort } = renderToPipeableStream(component, {
onShellReady() {
clearTimeout(timeoutId);
res.setHeader('Content-Type', 'text/html');
res.write('<!DOCTYPE html><html><head><title>Streaming</title></head><body><div id="root">');
pipe(res);
res.write('</div></body></html>');
res.end();
},
onShellError(error) {
clearTimeout(timeoutId);
res.status(500).send('Server error');
},
onError(error) {
console.error('Streaming error:', error);
// Content after the error still streams
// Only the errored Suspense boundary shows fallback
}
});
}
// Usage with different timeout for slow pages
app.get('/fast-page', (req, res) => {
streamPage(req, res, React.createElement(FastPage), 5000);
});
app.get('/slow-page', (req, res) => {
streamPage(req, res, React.createElement(SlowPage), 30000);
});
Common Mistakes
- Not setting the correct Content-Type. Streaming requires text/html content type. Without it, the browser may not render the streamed content properly.
- Using renderToString with Suspense. renderToString does not support Suspense. Use renderToPipeableStream or renderToReadableStream for Suspense-based streaming.
- No error handling for stream interruptions. Network errors can interrupt streams. Implement proper error handling with onError and onShellError callbacks.
- Suspense boundaries that are too large. Large Suspense boundaries defeat the purpose of streaming. Break pages into multiple small Suspense sections.
- Not considering SEO implications. Streaming may affect how search engines see your page. Ensure critical content and meta tags are in the initial shell, not in streaming sections.
Practice Questions
- How does streaming SSR differ from traditional SSR?
- How does renderToPipeableStream work in React 18?
- What is the purpose of Suspense boundaries in streaming SSR?
- How does streaming improve perceived performance?
- What error handling should you implement for streaming?
Challenge: Build a page with streaming SSR using React 18 renderToPipeableStream: a header that streams immediately, two Suspense boundaries with simulated slow data fetching (2s and 5s delays), a timeout mechanism that aborts streaming after 8 seconds, and proper error handling. Compare Time to First Byte with traditional renderToString.
FAQ
Mini Project
Build a streaming SSR product page: renderToPipeableStream with Suspense boundaries for product details (fast API), customer reviews (medium), and related products (slow). Each Suspense boundary has a skeleton fallback. Implement timeout and error handling. Compare TTFB, FCP, and LCP with a non-streaming version.
What's Next
You understand streaming SSR. Now explore Suspense SSR to learn how Suspense enables streaming and progressive rendering.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro