Hydration in React: What It Is and Why It Sometimes Goes Wrong
When a user visits a server-rendered React page, the server sends fully-formed HTML. The browser parses it and displays content immediately — no JavaScript needed for the initial paint. But that HTML is static. React needs to "boot up" on the client and attach event listeners, state, and all its reconciliation machinery to the existing DOM. This process is called hydration.
Understanding hydration explains why SSR pages load fast but feel interactive slightly later, why hydration errors are so hard to debug, and how newer patterns like partial hydration and streaming SSR are changing the picture.
The Two Phases of SSR
Phase 1 — Server render: React runs on the server, rendering the component tree to an HTML string (or stream). The output is sent to the browser. The user sees content almost immediately, but nothing is interactive yet. Buttons don't respond. Event handlers don't exist.
Phase 2 — Hydration: The browser loads the JavaScript bundle. React calls hydrateRoot(), walks the component tree, and matches each component to the existing DOM node. Instead of creating new DOM nodes, React attaches event listeners to the ones already there.
// Client entry point for SSR
import { hydrateRoot } from 'react-dom/client'
import App from './App'
hydrateRoot(document.getElementById('root'), <App />)
React expects the client-rendered output to match the server-rendered HTML exactly. If it does, no DOM mutations happen — React just attaches event listeners. This is why hydration is faster than a full client-side render.
What Hydration Actually Does
During hydration, React does a reconciliation pass where:
- It creates fiber nodes for every component (the in-memory representation)
- It matches fibers to existing DOM nodes rather than creating new ones
- It registers event listeners on the root
- It runs effects (
useEffect,useLayoutEffect) for the first time
React uses a single event delegation listener at the root rather than individual listeners per element, so the "attaching listeners" step is fast regardless of tree size.
Hydration Errors: When Client and Server Disagree
The most common React error in SSR applications:
Warning: Text content did not match.
Server: "Wednesday" Client: "Thursday"
This happens when the server and client render different output. Common causes:
Date/time values:
// ❌ Different on server and client
function Header() {
return <p>Today is {new Date().toLocaleDateString()}</p>
}
The server renders the date at request time; the client renders at hydration time — potentially a different day.
Random IDs:
// ❌ Different on every render
function Input() {
const id = Math.random().toString(36)
return <label htmlFor={id}>Name <input id={id} /></label>
}
Browser-only APIs:
// ❌ window is undefined on the server
function ThemeToggle() {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return <button>{isDark ? '☀️' : '🌙'}</button>
}
Fixing Hydration Mismatches
For browser-only values, delay reading them until after hydration with a client-side effect:
function ThemeToggle() {
const [isDark, setIsDark] = useState(false) // safe default for SSR
useEffect(() => {
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
}, [])
return <button>{isDark ? '☀️' : '🌙'}</button>
}
For dynamic content that legitimately differs between server and client (timestamps, random IDs), use the suppressHydrationWarning prop to tell React to accept the mismatch on that element:
<time suppressHydrationWarning dateTime={iso}>
{localFormatted}
</time>
Use this sparingly — it silences all hydration warnings on that element, not just the one you intended.
For components that should only render client-side, render null on the server and mount on the client:
function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return mounted ? children : null
}
The Hydration Performance Cost
Full hydration has a cost: the browser must download and parse the entire JavaScript bundle, execute it, reconcile the full component tree, and register all event listeners — even for components the user never interacts with.
On a slow network or device, there can be a multi-second window where the page looks interactive but isn't. This is the Time to Interactive (TTI) gap.
This cost has driven interest in:
- Partial hydration — only hydrate interactive components, leave static content as plain HTML
- Selective hydration (React 18) — hydrate components in order of user interaction priority
- Islands architecture — treat each interactive component as an isolated "island" of JavaScript
Conclusion
Hydration is React's process of attaching its runtime to server-rendered HTML. When it works correctly, it's invisible and fast. When client and server outputs diverge, you get hydration errors that can cause broken UI, flicker, or incorrect state. The fixes are consistent: use stable values on both sides, delay browser-only reads to useEffect, and use suppressHydrationWarning as a last resort. Understanding what hydration actually does — a reconciliation pass that reuses DOM nodes — helps you debug mismatches and design components that work correctly on both server and client.
