Concept
The beginner framing: server-side rendering (SSR) sends fully-formed HTML to the browser so users see content immediately, before any JavaScript has loaded; hydration is the step where React "wakes up" that static HTML and makes it interactive.
The precise mental model: hydration is not a normal client-side mount. An ordinary client-side render (no SSR) builds every DOM node from scratch. Hydration instead walks the already-existing, server-rendered DOM in parallel with the component tree React is about to render, matching each element by type and position, much like Reconciliation's matching rules, and reuses those existing nodes, attaching event listeners and internal state to them, rather than throwing them away and rebuilding.
// Server: renders this to a real HTML string, sent to the browser immediately
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Client: hydrateRoot() walks the EXISTING <h1>Hello, Ada!</h1> already in the
// DOM, confirms it matches what Greeting({name: "Ada"}) would produce, and
// attaches React's internal bookkeeping to that SAME node, no new <h1> created.This is why hydration is so much cheaper than a full client-side render from an empty page: the expensive work (building the DOM tree) already happened on the server; the client's job is comparison and wiring, not construction.
Hydration mismatches: when server and client disagree
If the HTML React expects to find (based on running the same component with the same props/state) doesn't match what the server actually sent, React can't safely reuse those DOM nodes, it logs a hydration mismatch warning and, for the affected subtree, falls back to discarding the server-rendered markup and rendering it fresh on the client, losing the fast-first-paint benefit for that part of the page.
function Timestamp() {
return <span>{new Date().toLocaleTimeString()}</span>; // ❌ different on server vs client
}The server renders this at, say, 10:04:02 AM; by the time the client hydrates a moment later, new Date().toLocaleTimeString() returns a different string, 10:04:03 AM, and React detects the mismatch.
Server responds with initial HTML skeleton over HTTP/2 stream.
Streaming: the same underlying mechanism, revisited
Streaming SSR, covered in depth in Suspense, is what lets a page's fast parts reach the browser without waiting for its slowest data dependency.
<Layout><Header /><Suspense fallback={<Skeleton />}><SlowWidget /> {/* data not ready on the server yet */}</Suspense></Layout>
The server doesn't wait for SlowWidget's data before sending anything, it streams the shell HTML plus the fallback's HTML right away, so the browser can paint immediately.
The reason this topic and Suspense share the same visualizer: streaming SSR is Suspense, applied at the server-rendering layer, a <Suspense> boundary around a slow component tells the server "send the fallback's HTML now, and stream the real content in a follow-up chunk once it's ready," instead of blocking the entire response on the slowest piece.
Try It
Predict what happens before checking the solution.
function UserBadge() {
const isMobile = typeof window !== "undefined" && window.innerWidth < 600;
return <span>{isMobile ? "📱" : "🖥️"}</span>;
}Does checking typeof window !== "undefined" safely avoid a hydration mismatch here?
Solution
No, it avoids a crash (since window genuinely doesn't exist on the server), but not a mismatch. On the server, typeof window !== "undefined" is always false, so the server always renders 🖥️. On the client, window exists, so isMobile is evaluated against the real viewport, if the actual browser window is narrower than 600px, the client would want to render 📱, which doesn't match what the server already sent. This is a textbook hydration mismatch: the branch itself doesn't crash, but its result differs between environments. The fix is to render the server-safe default first, then update to the client-accurate value inside a useEffect (which only ever runs client-side, after hydration has already completed against the server's version).
Implement It Yourself
Model the core hydration algorithm, matching existing DOM nodes rather than creating new ones:
function hydrate(domNode, element) {
if (domNode.nodeName.toLowerCase() !== element.type) {
console.warn("Hydration mismatch: expected", element.type, "found", domNode.nodeName);
return replaceWithFreshRender(domNode, element); // give up on reuse, render fresh
}
// Types match, REUSE this real DOM node, attach behavior to it directly.
attachEventListeners(domNode, element.props);
const domChildren = Array.from(domNode.childNodes);
element.props.children.forEach((childElement, i) => {
hydrate(domChildren[i], childElement); // recurse, matching position by position
});
return domNode;
This mirrors Reconciliation's type-matching rule almost exactly, the difference is that hydration's "before" tree is real, already-committed DOM from the server, not a previous virtual tree from an earlier client render.
Under the Hood
Hydration's walk-and-match approach is a direct extension of the type-and-position matching from Reconciliation, the only difference is what's being matched against: a previous virtual tree during ordinary reconciliation, versus real, already-parsed DOM nodes during hydration. And the reason a component's render must be deterministic given the same inputs, the actual root cause of every hydration mismatch, is the same purity requirement from Rendering Lifecycle: if render weren't required to produce the same output for the same props/state, there would be no reasonable expectation that a client render would ever match what the server already sent.
Common Mistakes
1. Using non-deterministic values directly during render
Covered above (Date.now(), Math.random(), toLocaleTimeString()), anything whose value can differ between the server's render and the client's hydration render will produce a mismatch. Defer such values to a useEffect (client-only, runs after hydration) if they genuinely need to differ from the server's version.
2. Branching on typeof window !== "undefined" inside render
Covered in Try It, this avoids a server-side crash but doesn't guarantee the result of that branch matches on both sides. Render the server-safe value first; update it client-side in an effect if needed.
3. Invalid HTML nesting
function Wrapper() {
return <p><div>Content</div></p>; // ❌ invalid HTML, browsers auto-correct this
}Browsers silently "fix" invalid HTML nesting (like a <div> inside a <p>) while parsing the server's response, before React ever gets a chance to hydrate against it, so the DOM React finds already differs structurally from what it rendered, guaranteeing a mismatch that has nothing to do with data at all.
Best Practices
- Keep render deterministic given the same props and state, the entire hydration model depends on this being true.
- Push genuinely environment-dependent values (viewport size, locale-formatted timestamps, random ids) into a
useEffect, rendering a server-safe default first and updating after hydration completes. - Use
suppressHydrationWarningsparingly, only for values you deliberately expect to differ in a harmless, cosmetic way (like a live clock), never as a blanket fix for an unexplained mismatch. - Validate HTML nesting against standard rules, invalid nesting causes browser-level DOM correction that hydration has no way to see coming.
Performance Tips
- Hydration's cost scales with how much of the page needs walking and wiring, selectively hydrating only the interactive parts first (rather than the whole page at once) prioritizes what the user is most likely to touch immediately.
- Streaming SSR (see Suspense) decouples first paint from the slowest data dependency, the biggest lever for perceived load time on data-heavy pages, well before any hydration-specific tuning matters.
- A hydration mismatch's fallback (discarding server markup and re-rendering client-side for that subtree) throws away the SSR benefit for exactly that part of the page, treating a mismatch warning as a real, fixable bug (not noise) protects the very thing SSR exists to provide.
