Server-Side Rendering: How Hydration Works
How SSR improves perceived performance, what hydration actually does, and where renderToString falls short compared to renderToPipeableStream.
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
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 interactiveCompare this to client-side React:
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 interactiveSSR'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:
// 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":
<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:
// 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:
<!-- ❌ 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.
// ❌ 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.
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:
renderToString | renderToPipeableStream | |
|---|---|---|
| Blocks until | Entire tree renders | Just the shell |
| Slow data inside Suspense | Delays entire response | Streams shell; fills in later |
| Simpler to set up | Yes | No |
| Use when | App renders quickly, no slow Suspense | App 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:
// ❌ 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.
- Create a new folder, run
npm init -y, add"type": "module"topackage.json - Install
fastify,@fastify/static,react,react-dom, andvite - Add
"build": "vite build"and"start": "node server.js"to your scripts - Write
index.html— script tag in<head>withasync defer type="module",<div id="root"><!--ROOT--></div>with no whitespace inside - Write
App.jswith auseStatecounter usingcreateElement— no JSX - Write
client.js— importhydrateRootand call it on#root - Write
server.js— split the shell on<!--ROOT-->at startup, flushheadfirst, writerenderToStringoutput, end withtail - Run
npm run buildthennpm run start, openlocalhost:3000, and view page source — you should see the full rendered HTML - Confirm the counter button works (hydration succeeded)
- Now add a blank line inside
<div id="root">inindex.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.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.