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 WHATWG ReadableStream 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

  1. Initial Shell: HTML for static parts and any components not wrapped in Suspense.
  2. Suspense Placeholders: Markers (<!--$!-->) indicate where suspended content will flow in.
  3. Chunked Payloads: When each Suspense boundary resolves, React streams its HTML chunk as a separate fragment wrapped in <div data-rsc> tags.
  4. 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, comparing renderToString 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:

  1. App.jsx: Wrap <PostList /> in <Suspense fallback={<SkeletonList />}>.
  2. Server: Use renderToPipeableStream as above.
  3. Benchmark: Compare renderToString vs streaming with wrk -t4 -c100 -d10s localhost:3000.
  4. 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