Hydration and streaming SSR

React · Expert · 18 min

SSR gives fast first paint and SEO; streaming and selective hydration make it fast, and hydration mismatches are a classic production bug.

Server-side rendering (SSR) runs your components on the server to produce HTML, so users see content before JavaScript loads. The client then hydrates: React renders the same tree in the browser and attaches event handlers and state to the existing DOM instead of recreating it.

// client entry
hydrateRoot(document.getElementById("root"), <App />);

React's streaming APIs are renderToPipeableStream (Node.js streams) and renderToReadableStream (Web Streams, for Deno, Bun, edge runtimes). With streaming, React sends the HTML shell immediately and leaves Suspense fallbacks where content isn't ready. As each boundary resolves on the server, React streams its HTML plus a tiny inline script that swaps it into place, without waiting for the slowest query. Callbacks like onShellReady and onShellError let you set the status code and handle failures; onAllReady waits for everything (useful for crawlers or static generation). For static generation there are prerender APIs in react-dom/static.

Hydration is also incremental. Selective hydration hydrates Suspense boundaries independently, and if the user clicks inside a boundary that isn't hydrated yet, React prioritises hydrating that one first.

Hydration requires the client's first render to produce exactly the same output as the server. A hydration mismatch happens when it doesn't, typically from Date.now(), Math.random(), typeof window checks, locale-dependent formatting, or invalid HTML nesting (a <div> inside a <p>) that the browser rewrote. React 19 logs a single error with a diff of what differed, then discards the server HTML up to the nearest Suspense boundary and renders that part on the client, losing the benefit of SSR there. Fixes: render client-only values in an effect after hydration, use useId for generated ids, and suppressHydrationWarning only for unavoidable one-element differences such as timestamps.

Frameworks wire all of this up; the concepts still decide where you put Suspense boundaries and why a page flickers.

Example

// server.jsx (Node + Express)
import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";

app.get("/", (req, res) => {
  let didError = false;
  const { pipe } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {                       // shell + Suspense fallbacks ready
      res.statusCode = didError ? 500 : 200;
      res.setHeader("Content-Type", "text/html");
      pipe(res);                           // later boundaries stream in
    },
    onShellError() {
      res.statusCode = 500;
      res.send("<h1>Something went wrong</h1>");
    },
    onError(err) {
      didError = true;
      console.error(err);
    },
  });
});

// client.jsx
// import { hydrateRoot } from "react-dom/client";
// hydrateRoot(document, <App url={location.pathname} />);

Remember

  • SSR renders HTML on the server; hydrateRoot attaches React to it on the client
  • Streaming sends the shell first and fills Suspense boundaries as data resolves
  • The first client render must match server HTML; avoid time, randomness and window checks in render

Watch out for

  • Rendering new Date().toLocaleTimeString() directly, so server and client output differ and hydration mismatches.

Check yourself

  1. A component renders typeof window === 'undefined' ? 'Server' : 'Client'. What happens on hydration?
  2. With streaming SSR, what does the browser receive first when one section is wrapped in Suspense and its data is slow?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/reference/react-dom/server/renderToPipeableStream

Builds on

Unlocks

Nothing builds on this yet.