| |

React 94 ⚛️ Streaming SSR and Progressive Hydration

⏱️ Estimated read: 17 min | |

Streaming SSR and progressive hydration are two sides of the same performance strategy. Streaming SSR changes when the server sends HTML: instead of waiting for the entire tree to render and sending one large response, the server sends the shell immediately and streams the slower regions as their data resolves . Progressive hydration changes when the client makes that HTML interactive: instead of hydrating the entire tree at once, React hydrates the components whose JavaScript has loaded and whose Suspense boundaries have resolved, while the rest of the page remains server-rendered and inert . The two mechanisms are coupled through Suspense: a boundary that streams late on the server also hydrates late on the client, which is what allows a slow data fetch to avoid blocking the rest of the page.

The React APIs that enable this are renderToPipeableStream for Node.js and renderToReadableStream for edge runtimes, both introduced in React 18 and still the core streaming entry points in React 19 . The server calls onShellReady when the shell has rendered, pipes the response, and continues streaming Suspense boundaries as they resolve. The client uses hydrateRoot to attach interactivity, and React’s selective hydration algorithm prioritizes the boundaries the user interacts with first, even if their JavaScript has not finished loading . The combination produces a page that paints before the slowest component is ready and becomes interactive in the order the user needs it, not the order the server rendered it.

This chapter covers the streaming server APIs and their callbacks, the Suspense-aware delivery model that replaces the fallback with streamed HTML, the selective hydration algorithm that prioritizes user interaction, the common sources of hydration mismatch that force a full re-render, and the code-splitting strategies that reduce the JavaScript that must load before hydration can begin. It assumes familiarity with React Server Components and Suspense from the preceding chapters.

Key point: Streaming SSR sends the shell first and the slow regions later; progressive hydration makes the regions interactive as their JavaScript loads and their boundaries resolve. The Suspense boundary is the unit of both. A page wrapped in one root Suspense boundary has a shell that is just a spinner—the streaming provides no benefit. The shell must be minimal but complete, with real layout and enough context for the user to understand the page before the slow data arrives .


Why streaming SSR and progressive hydration are coupled

Traditional SSR blocks on the slowest component. renderToString renders the entire tree, waits for every data fetch, and sends one complete HTML string. The browser shows nothing until the slowest component finishes, and the time to first byte is the time to render the entire page . Streaming SSR breaks this dependency: the server renders the shell, sends it, and then streams each Suspense boundary’s HTML as its data resolves. The browser parses and paints the shell while the slow regions are still being generated.

Hydration is the second bottleneck. Even after the HTML arrives, the page is not interactive until React loads the component JavaScript, walks the server-rendered DOM, and attaches event listeners. If the entire tree hydrates at once, the main thread is blocked for the duration, and the user cannot interact with any part of the page until the whole tree is ready . Progressive hydration, also called selective hydration, breaks this into per-boundary work: React hydrates the boundaries whose JavaScript has loaded, and if the user interacts with a boundary that has not hydrated yet, React prioritizes that boundary and hydrates it immediately .

Suspense is the shared unit. The server streams a boundary when its data resolves; the client hydrates a boundary when its JavaScript loads and the user needs it. The boundary is the contract between the two mechanisms. A component outside any Suspense boundary is part of the shell: it must render synchronously, it cannot suspend, and it hydrates as part of the initial shell hydration. A component inside a boundary is streamed and hydrated independently.

The trade-off. Streaming and progressive hydration add complexity to the server entry point and the component tree. The server must handle the streaming callbacks, the client must ensure that the JavaScript for each boundary loads in a reasonable order, and the developer must avoid hydration mismatches that force React to discard the server HTML and re-render from scratch . The alternative—blocking SSR and full-tree hydration—is simpler but produces a slower first paint and a longer time to interactive.


a. The streaming server APIs and the shell callback

renderToPipeableStream is the Node.js entry point. It returns an object with pipe and abort methods, and it accepts callbacks that fire at specific stages of the render . The bootstrapScripts option points the browser at the client bundle, which calls hydrateRoot when it loads.

// server.js — Node.js streaming SSR.
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

app.use('*', (req, res) => {
  let didError = false;

  const { pipe, abort } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/static/js/bundle.js'],
    onShellReady() {
      // The shell is ready: send headers and start streaming.
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },
    onShellError(error) {
      // The shell failed before any bytes were sent.
      res.statusCode = 500;
      res.send('<!doctype html><p>Loading failed</p>');
    },
    onError(error) {
      didError = true;
      console.error(error);
    },
  });

  // Optional: abort if the render takes too long.
  setTimeout(abort, 10000);
});

The onShellReady callback is the point where the shell has rendered and the response can begin. The shell is everything outside any Suspense boundary. If the shell itself suspends, onShellError fires instead, and the server can still send a fallback document with a 500 status . The onAllReady callback is the alternative for cases where the full HTML is needed before sending—crawlers, static generation—but it eliminates the streaming benefit.

renderToReadableStream is the edge-runtime equivalent. It returns a ReadableStream that can be passed directly to a Response object. The callback structure is different because Web Streams do not have the same shell-ready event, but the Suspense-aware streaming behavior is the same .


b. Suspense-aware delivery and the fallback replacement

Streaming SSR is not just sending bytes sooner. It is coordinated with Suspense. When the server encounters a Suspense boundary whose child suspends, it sends the fallback HTML immediately, then continues rendering the rest of the shell and other boundaries. When the suspended child’s data resolves, the server sends the completed HTML along with an inline script that replaces the fallback with the real content .

// App.tsx — Suspense boundaries enable streaming.
import { Suspense } from 'react';

export default function App() {
  return (
    <html>
      <body>
        <Header />
        <Suspense fallback={<ProductSkeleton />}>
          <ProductDetails />
        </Suspense>
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews />
        </Suspense>
        <Footer />
      </body>
    </html>
  );
}

The Header and Footer are part of the shell. They render synchronously and are sent in the first chunk. ProductDetails and Reviews are inside separate Suspense boundaries. If ProductDetails resolves faster than Reviews, its HTML is streamed first, and the Reviews fallback remains visible until that boundary resolves. The boundaries are independent; one slow section does not block the other .

The anti-pattern is wrapping the entire application in one Suspense boundary. The shell becomes a spinner, and the streaming provides no benefit because nothing meaningful is sent until the slowest data resolves. The shell should include the layout, the navigation, and any static content that gives the user context while the dynamic sections load .


c. Selective hydration and the interaction priority

On the client, hydrateRoot attaches React to the server-rendered HTML. React does not hydrate the entire tree synchronously. It hydrates the shell first, then walks the Suspense boundaries. Each boundary hydrates when its JavaScript chunk has loaded and the boundary is ready. If the user clicks a button inside a boundary that has not hydrated yet, React’s selective hydration algorithm prioritizes that boundary: it hydrates it immediately, even if other boundaries are still waiting .

This behavior is what makes progressive hydration practical. The initial JavaScript bundle contains the shell and the most critical components. Lower-priority boundaries load their JavaScript lazily, and their hydration is deferred until the code is available or the user interacts with them. The page is interactive in the parts that matter first, and the remaining parts become interactive as their code loads .

// Client entry — hydrateRoot attaches to the server HTML.
import { hydrateRoot } from 'react-dom/client';
import App from './App';

hydrateRoot(document.getElementById('root'), <App />);

The code-splitting strategy determines how quickly each boundary can hydrate. A boundary whose component is in the initial bundle hydrates as soon as React reaches it. A boundary whose component is lazy-loaded hydrates only after the dynamic import resolves. The trade-off is between initial bundle size and hydration speed for lower-priority sections .


d. Hydration mismatches and the re-render penalty

A hydration mismatch occurs when the client’s first render of a component produces a different DOM than the server’s HTML. React cannot attach event listeners to nodes that do not match, so it discards the mismatched subtree and re-renders it from scratch on the client. The server’s work is wasted for that subtree, and the user sees a flicker as the content is replaced .

The common causes are non-deterministic values that differ between server and client. new Date() renders the server’s timezone on the server and the client’s timezone on the client. Math.random() produces different values. localStorage is unavailable on the server. Browser extensions inject DOM nodes before React hydrates, and the injected nodes are not in the server HTML .

The fixes are the same as for any SSR hydration issue. Move browser-only values into useEffect, which runs only on the client, and render a deterministic placeholder during the initial render. Use useId for IDs that must be stable across server and client instead of generating a random ID. Guard window and document access with a check for the client environment, and defer the value to an effect .

// Avoid: non-deterministic value in render.
function BadClock() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}

// Fix: defer the client-only value to an effect.
function GoodClock() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  if (time === null) return <span>--:--</span>;
  return <span>{time}</span>;
}

A mismatch inside a Suspense boundary is less catastrophic than a mismatch in the shell, because React can re-render the boundary without discarding the entire page. But it still wastes the server’s work for that boundary and delays interactivity. The goal is zero mismatches, not fewer.


Complete Example Session

// ============================================
// PART 1: SERVER ENTRY WITH STREAMING
// ============================================
// server.tsx
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

export function render(req, res) {
  let didError = false;

  const { pipe, abort } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/static/js/bundle.js'],
    onShellReady() {
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },
    onShellError(error) {
      res.statusCode = 500;
      res.send('<!doctype html><p>Loading failed</p>');
    },
    onError(error) {
      didError = true;
      console.error(error);
    },
  });

  setTimeout(abort, 10000);
}

// ============================================
// PART 2: APP WITH SUSPENSE BOUNDARIES
// ============================================
// App.tsx
import { Suspense } from 'react';
import { Header } from './Header';
import { ProductDetails } from './ProductDetails';
import { Reviews } from './Reviews';
import { Footer } from './Footer';

export default function App() {
  return (
    <html>
      <body>
        {/* Shell: synchronous, part of the first chunk. */}
        <Header />

        {/* Slow boundary: streamed when data resolves. */}
        <Suspense fallback={<ProductSkeleton />}>
          <ProductDetails />
        </Suspense>

        {/* Independent slow boundary. */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews />
        </Suspense>

        {/* Shell: synchronous. */}
        <Footer />
      </body>
    </html>
  );
}

// ============================================
// PART 3: ASYNC COMPONENT INSIDE SUSPENSE
// ============================================
// ProductDetails.tsx
async function ProductDetails({ id }: { id: string }) {
  const product = await fetchProduct(id);
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </article>
  );
}

// ============================================
// PART 4: CLIENT HYDRATION ENTRY
// ============================================
// client.tsx
import { hydrateRoot } from 'react-dom/client';
import App from './App';

hydrateRoot(
  document.getElementById('root'),
  <App />
);

// ============================================
// PART 5: AVOIDING HYDRATION MISMATCH
// ============================================
// Bad: non-deterministic value in render.
function BadTimestamp() {
  return <span>{Date.now()}</span>;
}

// Good: deterministic placeholder, client value in effect.
function GoodTimestamp() {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => setNow(Date.now()), []);
  if (now === null) return <span>--</span>;
  return <span>{now}</span>;
}

// ============================================
// PART 6: STABLE IDS WITH useId
// ============================================
// Bad: random ID.
function BadInput() {
  const id = Math.random().toString(36);
  return <input id={id} />;
}

// Good: stable ID across server and client.
function GoodInput() {
  const id = useId();
  return <input id={id} />;
}

// ============================================
// PART 7: LAZY-LOADED BOUNDARY COMPONENT
// ============================================
// Heavy component not in the initial bundle.
import { lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

// Inside a Suspense boundary:
<Suspense fallback={<ChartSkeleton />}>
  <HeavyChart data={data} />
</Suspense>

// ============================================
// PART 8: INTERACTION PRIORITY
// ============================================
// If the user clicks inside <Reviews> before it hydrates,
// React prioritizes hydrating that boundary.
// The click is queued and replayed after hydration.

// ============================================
// PART 9: THE FALLBACK REPLACEMENT
// ============================================
// Server sends:
// <div id="reviews"><!--fallback: skeleton--></div>
// Later, when data resolves:
// <div id="reviews"><!--$-->...real HTML...<!--/--></div>
// Inline script swaps the content.

// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. Server renders shell synchronously.
// 2. onShellReady fires; pipe response.
// 3. Suspense fallbacks sent for slow boundaries.
// 4. As data resolves, HTML chunks stream.
// 5. Client parses HTML and paints progressively.
// 6. Client JS loads; hydrateRoot attaches.
// 7. React hydrates shell first, then boundaries.
// 8. User interaction prioritizes a boundary.

Quick Reference

Streaming APIs

APIEnvironmentReturns
renderToPipeableStreamNode.js{ pipe, abort }
renderToReadableStreamWeb Streams (edge)ReadableStream
hydrateRootClientHydrates server HTML

Server Callbacks

CallbackWhenUse
onShellReadyShell renderedSend headers, pipe response
onShellErrorShell failedSend 500 fallback
onAllReadyFull render completeCrawlers, static HTML
onErrorAny errorLog, track

Hydration Mismatch Causes

CauseFix
new Date() in renderDefer to useEffect
Math.random() in renderDefer or seed
localStorage/windowGuard with client check
Random IDsuseId()
Browser extension injectionsuppressHydrationWarning (narrow)

Suspense Boundary Strategy

PatternEffect
One root boundaryShell is a spinner; no benefit
Shell + multiple boundariesFast paint, progressive reveal
Lazy-loaded boundarySmaller initial bundle, later hydration

Best Practices

✅ Do This:

// Use renderToPipeableStream in Node.                        // ✅
const { pipe } = renderToPipeableStream(<App />, { onShellReady() { pipe(res); } });

// Keep the shell meaningful.                                 // ✅
<Header /><Suspense fallback={...}><Slow /></Suspense><Footer />

// Defer non-deterministic values to useEffect.               // ✅
const [time, setTime] = useState(null); useEffect(() => setTime(Date.now()), []);

// Use useId for stable IDs.                                  // ✅
const id = useId();

// Lazy-load heavy boundary components.                       // ✅
const Heavy = lazy(() => import('./Heavy'));

❌ Don’t Do This:

// Wrap the entire app in one Suspense boundary.              // ❌
<Suspense fallback={<Spinner />}><EntireApp /></Suspense>

// Render Date.now() or Math.random() directly.               // ❌
<span>{Date.now()}</span>

// Access window or localStorage in render.                   // ❌
const theme = localStorage.getItem('theme');

// Use renderToString for dynamic pages.                      // ❌
// Blocks until the slowest component finishes.

Common Pitfalls

PitfallWhy It HappensFix
Shell is a spinnerOne root Suspense boundarySplit into multiple boundaries
Hydration mismatch flickerNon-deterministic renderDefer to useEffect or useId
Slow time to interactiveLarge initial bundleLazy-load boundary components
Status code already sentStreaming started before errorCheck didError in onShellReady
Streaming not workingProxy buffers responseDisable buffering in proxy/CDN

Real-World Examples

1. Streaming Server Entry

const { pipe } = renderToPipeableStream(<App />, { bootstrapScripts: ['/bundle.js'], onShellReady() { pipe(res); } });

2. Shell with Boundaries

<Header /><Suspense fallback={<Skeleton />}><Slow /></Suspense><Footer />

3. Client Hydration

hydrateRoot(document.getElementById('root'), <App />);

4. Deferred Client Value

const [time, setTime] = useState(null); useEffect(() => setTime(Date.now()), []);

5. Stable ID

const id = useId();

6. Lazy Boundary Component

const Chart = lazy(() => import('./Chart'));

7. Shell Error Fallback

onShellError() { res.status(500).send('<!doctype html><p>Failed</p>'); }

8. Abort Timeout

setTimeout(abort, 10000);

9. Error Tracking

onError(error) { didError = true; console.error(error); }

10. Edge Stream

const stream = await renderToReadableStream(<App />);
return new Response(stream);

Visual: Streaming SSR Flow

┌──────────────────────────────────────────────┐
│  SERVER                                      │
│                                              │
│  renderToPipeableStream(<App />)             │
│       │                                      │
│       ├── Shell renders (Header, Footer)     │
│       │       │                              │
│       │       ▼                              │
│       │   onShellReady → pipe(res)           │
│       │       │                              │
│       │       ▼                              │
│       │   Send shell HTML + fallbacks        │
│       │                                      │
│       ├── ProductDetails resolves            │
│       │       │                              │
│       │       ▼                              │
│       │   Stream HTML + inline swap script   │
│       │                                      │
│       └── Reviews resolves                   │
│               │                              │
│               ▼                              │
│           Stream HTML + inline swap script   │
│                                              │
└──────────────────────────────────────────────┘

Progressive Hydration Flow

┌──────────────────────────────────────────────┐
│  CLIENT                                      │
│                                              │
│  HTML arrives. Browser paints shell.         │
│       │                                      │
│       ▼                                      │
│  hydrateRoot(App)                            │
│       │                                      │
│       ├── Hydrate shell (Header, Footer)     │
│       │                                      │
│       ├── Suspense boundary 1: JS loaded?    │
│       │       ├── Yes → hydrate              │
│       │       └── No → wait                  │
│       │                                      │
│       └── Suspense boundary 2: JS loaded?    │
│               ├── Yes → hydrate              │
│               └── No → wait                  │
│                                              │
│  User clicks inside boundary 2:              │
│    React prioritizes boundary 2 hydration.   │
│    Click is queued and replayed.             │
│                                              │
└──────────────────────────────────────────────┘

Fallback Replacement

┌──────────────────────────────────────────────┐
│  STREAMED HTML                               │
│                                              │
│  Initial chunk:                              │
│  <div id="reviews">                          │
│    <p>Loading reviews...</p>  ← fallback     │
│  </div>                                      │
│                                              │
│  Later chunk:                                │
│  <div id="reviews">                          │
│    <script>                                  │
│      // Replace fallback with real content   │
│    </script>                                 │
│    <ul><li>Review 1</li>...</ul>             │
│  </div>                                      │
│                                              │
│  Browser swaps the content in place.         │
│                                              │
└──────────────────────────────────────────────┘

Hydration Mismatch Penalty

┌──────────────────────────────────────────────┐
│  HYDRATION MISMATCH                          │
│                                              │
│  Server HTML: <span>10:00 AM</span>          │
│  Client render: <span>10:00:01 AM</span>     │
│       │                                      │
│       ▼                                      │
│  React cannot attach listeners.              │
│       │                                      │
│       ▼                                      │
│  Discard subtree. Re-render from scratch.    │
│       │                                      │
│       ▼                                      │
│  Server work wasted. Flicker.                │
│                                              │
│  Fix: deterministic render, useEffect.       │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Server API (Node)renderToPipeableStream
Server API (edge)renderToReadableStream
Client APIhydrateRoot
Shell callbackonShellReady
Streaming unitSuspense boundary
Hydration unitSuspense boundary
Interaction prioritySelective hydration prioritizes clicked boundary
Mismatch causeNon-deterministic render values
Mismatch fixuseEffect, useId, client guards

Key takeaways:

  • Streaming SSR sends the shell first and the slow regions later. renderToPipeableStream renders the shell synchronously, calls onShellReady, and pipes the response. Suspense boundaries stream their HTML as their data resolves, with inline scripts that replace the fallbacks .
  • The shell must be meaningful. Wrapping the entire app in one Suspense boundary makes the shell a spinner and eliminates the streaming benefit. The shell should include layout, navigation, and static content that gives the user context .
  • Progressive hydration makes boundaries interactive independently. React hydrates the shell first, then each boundary as its JavaScript loads. If the user interacts with a boundary that has not hydrated, React prioritizes that boundary .
  • Hydration mismatches force a full re-render of the subtree. Non-deterministic values—timestamps, random IDs, browser APIs—produce different server and client output. Defer them to useEffect, use useId for stable IDs, and guard browser-only access .
  • Code splitting determines hydration speed. A boundary whose component is in the initial bundle hydrates immediately. A lazy-loaded boundary waits for its chunk. Lazy-loading reduces the initial bundle but delays hydration for that section .
  • The status code cannot change after streaming starts. Check didError in onShellReady and send a 500 fallback if the shell failed. Errors inside Suspense boundaries can often recover on the client without changing the status .
  • Proxies and CDNs must not buffer the response. Streaming depends on the server flushing chunks as they are generated. A reverse proxy that buffers the full response negates the benefit .

Remember: Streaming SSR and progressive hydration are two phases of the same Suspense contract. The server streams a boundary when its data resolves; the client hydrates that boundary when its JavaScript loads and the user needs it. The shell—everything outside any Suspense boundary—must be minimal but complete, because it is the only thing the user sees before the slow regions arrive. The boundaries must be independent, because a boundary that waits for another boundary’s data is not streaming; it is blocking with extra steps. Hydration mismatches are the enemy: a single non-deterministic value can force React to discard the server’s work and re-render from scratch. The APIs are renderToPipeableStream on the server and hydrateRoot on the client, but the architecture is Suspense boundaries that map to the page’s independent regions.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!