Rendering
Streaming SSR
Progressive HTML rendering for better UX
Advanced
ssr react performance streaming
Definition
Streaming SSR sends HTML progressively from the server to the browser as it becomes available, rather than waiting for the entire page to render. This reduces Time to First Byte (TTFB) and allows the browser to start parsing and displaying content earlier.
How Streaming SSR Works
Traditional SSR: Streaming SSR:
Server: Server:
1. Render entire app 1. Render shell <html>...<head>
2. Wait for all data 2. Send immediately
3. Generate full HTML 3. Render component 1
4. Send response 4. Stream <component-1>
5. Render component 2
Client: 6. Stream </component-1>
5. Receive HTML 7. Continue...
6. Display page
7. Hydrate Client:
2. Receive <html>...<head>
3. Start parsing CSS
4. Display partial content
5. Receive more HTML
6. Continue rendering
React 18 Streaming
renderToPipeableStream
// server.js
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/main.js'],
onShellReady() {
// Shell ready - start streaming
res.statusCode = 200;
res.setHeader('Content-type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
res.statusCode = 500;
},
}
);
});
Streaming with Suspense
// App.js
import { Suspense } from 'react';
function App() {
return (
<html>
<head>...</head>
<body>
<header>Static header</header>
<Suspense fallback={<Spinner />}>
<SlowComponent /> <!-- Streamed when ready -->
</Suspense>
<footer>Static footer</footer>
</body>
</html>
);
}
async function SlowComponent() {
const data = await fetchData(); // Slow API call
return <div>{data.content}</div>;
}
Inline Fallbacks
// React streams fallback HTML first
<div id="S:1"><!--$?-->
<div class="spinner">Loading...</div>
</div>
// Later, when data ready
<script>
$RC=function(b,c,e){c=document.getElementById(c);c.parentNode...</script>
<div id="S:1">
<!-- Actual content -->
<div>Loaded content</div>
</div>
</template>
Benefits
1. Faster TTFB
// Traditional: Wait for all data
// TTFB: 800ms (waiting for slow API)
// Streaming: Send shell immediately
// TTFB: 50ms (shell sent)
// Complete: 800ms (content streamed)
2. Progressive Hydration
// Browser can hydrate components as they arrive
// No need to wait for full HTML
// Interactive parts work while others loading
<Suspense>
<InteractiveHeader /> <!-- Hydrated immediately -->
<SlowContent /> <!-- Hydrated when streamed -->
</Suspense>
3. Better Error Handling
renderToPipeableStream(
<App />,
{
onShellReady() {
// Shell rendered successfully
// Can start streaming
},
onError(error) {
// Handle error gracefully
// Can still stream partial content
},
}
);
Implementation Patterns
Selective Hydration
// Use defer for non-critical components
import { Suspense, lazy } from 'react';
const Comments = lazy(() => import('./Comments'));
function Post() {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Suspense fallback={<CommentsSkeleton />}>
<Comments postId={post.id} />
</Suspense>
</article>
);
}
Data Fetching
// Server-side data fetching with streaming
async function ProductPage({ id }) {
// Start fetching immediately
const productPromise = fetchProduct(id);
const reviewsPromise = fetchReviews(id);
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails promise={productPromise} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews promise={reviewsPromise} />
</Suspense>
</div>
);
}
async function ProductDetails({ promise }) {
const product = await promise;
return <ProductCard product={product} />;
}
Next.js Implementation
app Router (Streaming by Default)
// app/page.js
import { Suspense } from 'react';
export default async function Page() {
return (
<>
<header>Static header</header>
<Suspense fallback={<Loading />}>
<Posts /> <!-- Automatically streamed -->
</Suspense>
</>
);
}
async function Posts() {
const posts = await getPosts(); // Slow fetch
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
Performance Considerations
Chunking
// Break large pages into chunks
// Each Suspense boundary becomes a chunk
<Suspense>
<Header /> <!-- Chunk 1 -->
</Suspense>
<Suspense>
<MainContent /> <!-- Chunk 2 -->
</Suspense>
<Suspense>
<Footer /> <!-- Chunk 3 -->
</Suspense>
Compression
// Use gzip/brotli for streaming content
app.get('/', (req, res) => {
res.setHeader('Content-Encoding', 'gzip');
// ... renderToPipeableStream
});
Debugging
Chrome DevTools
1. Network tab
2. Look for streaming response
3. Check Timing: TTFB vs Content Download
4. Streaming shows immediate TTFB, gradual download
Logging
const { pipe } = renderToPipeableStream(
<App />,
{
onShellReady() {
console.log('Shell ready at:', performance.now());
},
onAllReady() {
console.log('All ready at:', performance.now());
},
}
);
Key Takeaway
Streaming SSR sends HTML progressively, improving perceived performance. Use React 18’s renderToPipeableStream, wrap slow components with Suspense, and leverage selective hydration. The shell renders immediately while dynamic content streams in, giving users faster visual feedback.