Server-Side Rendering: How Hydration Works

How SSR improves perceived performance, what hydration actually does, and where renderToString falls short compared to renderToPipeableStream.

April 4, 20268 min read3 / 3

The previous post covered SSG, where pages are built once at deploy time. SSR is the next step up: the server renders React fresh on every request and sends the resulting HTML to the browser immediately.

The critical difference from SSG is hydration: React attaching itself to existing server-rendered HTML without re-rendering it from scratch.

The SSR Flow

Plain text
1. Browser makes GET / 2. Server runs React → generates HTML string → sends it immediately 3. Browser displays the HTML (user sees content, fast first paint) 4. Browser downloads bundle.js in the background 5. React calls hydrateRoot() — attaches event listeners to existing DOM 6. App is now interactive

Compare this to client-side React:

Plain text
1. Browser makes GET / 2. Server sends empty <div id="root"> + bundle.js 3. Browser downloads bundle.js (user sees nothing) 4. React renders the full app into the DOM 5. App is interactive

SSR's win is steps 3 and 4: the user sees something while the bundle is still downloading.

renderToString vs hydrateRoot

The server uses renderToString. The client uses hydrateRoot. They are a matched pair.

renderToString vs renderToStaticMarkup: renderToString embeds React's hydration metadata into the output. These are the extra attributes React needs to take over the DOM on the client. renderToStaticMarkup strips all of that out. If you use renderToString and never hydrate, the app still works; it's just larger than it needs to be. The rule: use renderToString when you intend to hydrate; use renderToStaticMarkup when you don't.

Flush the head first

The key optimisation in the server handler is to split on <!--ROOT--> and write the first part immediately, before React even starts rendering. This lets the browser begin downloading your CSS and JavaScript in parallel while the server is still processing the React component tree:

JavaScript
// Split once at startup — not on every request const [head, tail] = readFileSync('dist/index.html', 'utf-8').split('<!--ROOT-->'); app.get('/', (req, reply) => { // 1. Flush the <head> immediately - browser starts fetching CSS/JS now reply.raw.write(head); // 2. Render the React app (this is the slow part) const reactApp = renderToString(h(App)); reply.raw.write(reactApp); // 3. Send the closing tags reply.raw.end(tail); });

Order matters: flushing the head first makes SSR maximally concurrent. The user's browser starts downloading your script bundle the moment the <head> arrives, not after the server finishes rendering React.

The script tag goes in <head>, not <body>

Place your client bundle script in <head> with async defer and type="module":

HTML
<head> <!-- async defer: start downloading, don't block parsing, execute after DOM is ready --> <script src="./client.js" async defer type="module"></script> </head> <body> <div id="root"><!--ROOT--></div> </body>

Without async defer, the browser stops parsing HTML the moment it hits the script tag, defeating the whole point of flushing the head early.

client.js is your browser-only boundary

The server imports App.js directly. It never imports client.js. That means anything you put in client.js is guaranteed to only run in the browser. It is a safe place for analytics, window-dependent code, and any third-party library that crashes in Node:

JavaScript
// client.js - only ever runs in the browser import { hydrateRoot } from 'react-dom/client'; import { createElement as h } from 'react'; import App from './App.js'; // Safe to use window, document, localStorage here hydrateRoot(document.getElementById('root'), h(App));

What Hydration Actually Does

Hydration is not a re-render. React walks the existing DOM tree and the virtual DOM tree simultaneously, matching them up. It adds event listeners (onClick, onChange, etc.) to the existing DOM nodes.

If there is a mismatch between the server-rendered HTML and what React expects to render on the client, React logs a hydration error and falls back to a full client-side re-render for the mismatching subtree.

Whitespace is a real source of mismatches. React hashes the server-rendered output and compares it to the hash produced by the client render. Even a stray newline or space inside your root element can cause the hashes to differ. React gives up and re-renders from scratch on the client, giving you all the downside of SSR (server load, complexity) with none of the upside (fast first paint).

This only matters in the actual HTML output, not your component source files. It's the content inside <div id="root"> in your built index.html that must be tight:

HTML
<!-- ❌ Whitespace inside root causes hash mismatch --> <div id="root"> </div> <!-- ✅ No whitespace — the placeholder comment is fine --> <div id="root"><!--ROOT--></div>

Watch out for auto-formatters. If Prettier or your editor's format-on-save wraps content inside <div id="root"> onto a new line, you will get a mismatch. The fix is the same: keep <div id="root"><!--ROOT--></div> on one line with no interior whitespace. The formatter only needs to avoid touching that one line.

Other common mismatch causes: Math.random(), Date.now(), or any value that differs between server and client runs.

JavaScript
// ❌ This will cause a hydration mismatch — Math.random() differs between server and client function Card() { return <div id={`card-${Math.random()}`}>...</div>; } // ✅ Use a stable, deterministic value function Card({ id }: { id: string }) { return <div id={`card-${id}`}>...</div>; }

renderToString vs renderToPipeableStream

renderToString blocks until the entire component tree has rendered, then sends the result as one chunk.

renderToPipeableStream streams HTML as React renders it. It is useful when some parts of the page are slow (a data fetch for a sidebar) but you don't want to delay the fast parts.

JavaScript
import { renderToPipeableStream } from 'react-dom/server'; app.get('/', (req, reply) => { const { pipe } = renderToPipeableStream(<App />, { onShellReady() { // Send the initial shell as soon as it's ready // even if Suspense boundaries are still loading reply.header('Content-Type', 'text/html'); pipe(reply.raw); }, onError(err) { console.error(err); }, }); });

The difference in practice:

renderToStringrenderToPipeableStream
Blocks untilEntire tree rendersJust the shell
Slow data inside SuspenseDelays entire responseStreams shell; fills in later
Simpler to set upYesNo
Use whenApp renders quickly, no slow SuspenseApp has slow data inside Suspense boundaries

For simple apps where everything renders in one shot, renderToString and renderToPipeableStream produce identical results. There are no chunks to stream. Use renderToString there; it's simpler. renderToPipeableStream is only worth the added complexity when your app genuinely has slow parts (a database query inside a Suspense boundary) that would otherwise block the fast parts from reaching the user. If your app is simple enough that renderToString feels equivalent, that's a signal you probably don't need SSR at all.

Shortcut with Vite: Vite has a built-in SSR mode. Run vite build --ssr to get an SSR-optimised bundle without custom Webpack configuration. For a new project that doesn't need the full Next.js feature set, this is the fastest path to a working SSR setup.

The SSR Gotchas

Browser APIs crash on the server. window, document, localStorage, navigator, IntersectionObserver: none of these exist in Node.js. Any library or component that uses them must be guarded:

JavaScript
// ❌ Crashes on server const width = window.innerWidth; // ✅ Safe const width = typeof window !== 'undefined' ? window.innerWidth : 0; // ✅ Better — use useEffect (only runs on client) const [width, setWidth] = useState(0); useEffect(() => { setWidth(window.innerWidth); }, []);

Measure before you ship. SSR adds server load, deployment complexity, and a whole class of bugs. On fast devices with good connections, it is sometimes slower than pure client-side rendering because the server must finish rendering before sending anything.

The two metrics that matter are time to first meaningful paint (when the user sees something) and time to interactive (when they can act on it). SSR specifically improves the gap between them. If your users are on fast devices with fast connections, that gap is already tiny. SSR buys you nothing and costs you complexity.

At Netflix, they shipped SSR, measured it, and found it made things worse in certain parts of the app. They rolled it back. Know your user:

  • Fast device + good connection (Uber for helicopters in San Francisco) → SSR is likely not worth it
  • Slow device + patchy connection (a crop-tracking app for rural users) → SSR can make a meaningful difference

Tools to measure: Chrome DevTools (Performance tab shows FMP and TTI), Lighthouse (gives you a score for each metric), and any server-side APM to capture server render time. You need both sides. A slow server render can cancel out the perceived performance gain entirely.

Build This

Build the SSR server from scratch. This is the best way to understand what frameworks hide from you.

  1. Create a new folder, run npm init -y, add "type": "module" to package.json
  2. Install fastify, @fastify/static, react, react-dom, and vite
  3. Add "build": "vite build" and "start": "node server.js" to your scripts
  4. Write index.html — script tag in <head> with async defer type="module", <div id="root"><!--ROOT--></div> with no whitespace inside
  5. Write App.js with a useState counter using createElement — no JSX
  6. Write client.js — import hydrateRoot and call it on #root
  7. Write server.js — split the shell on <!--ROOT--> at startup, flush head first, write renderToString output, end with tail
  8. Run npm run build then npm run start, open localhost:3000, and view page source — you should see the full rendered HTML
  9. Confirm the counter button works (hydration succeeded)
  10. Now add a blank line inside <div id="root"> in index.html, rebuild and restart — watch the hydration error appear in the console

Stretch goal: Replace renderToString with renderToPipeableStream. Wrap a slow section of your app in a Suspense boundary with a two-second artificial delay inside. Watch how the fast parts arrive in the browser before the slow part finishes rendering on the server.

Practice what you just read.

Build an SSR Server from ScratchQuiz: Server-Side Rendering & Hydration
2 exercises

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.