Hydration and Hydration Errors
How server HTML becomes an interactive app, why the first client render must match it exactly, what causes mismatches, and how to hydrate less.
With SSR (server-side rendering) the browser first receives finished HTML it can paint immediately, but nothing on the page responds yet. Hydration is the step where the framework's JavaScript loads, renders the same component tree again in the browser, and walks the existing DOM (Document Object Model) attaching event listeners and state to it instead of creating new nodes. That only works if the first client render produces exactly the markup the server sent; when it does not, you get a hydration error, and the framework throws away part of the server HTML and renders it again on the client.
Context
Classic server apps (PHP, Rails, Django) sent HTML and added a little jQuery on top; the server owned the markup and the script only decorated it. Single-page apps of the early 2010s flipped this: the server sent an empty <div id="root"> and JavaScript built everything, which meant a blank screen until the bundle ran and poor indexing by crawlers. "Isomorphic" or "universal" JavaScript (Airbnb popularised the term in 2013) combined the two: run the same components on the server to produce HTML, then let the client take that HTML over. React has supported this since its early versions; the API was named ReactDOM.hydrate in React 16 (2017) and became hydrateRoot in React 18 (2022). Vue, Svelte, Solid and Angular all have their own equivalent.
You have met it if you use Next.js, Remix, Nuxt or SvelteKit, which hydrate every server-rendered page for you, and you have certainly met the error: a red overlay in development saying the server-rendered HTML did not match the client. In a hand-rolled setup, the client entry point is a single call:
import {hydrateRoot} from 'react-dom/client'
import App from './App'
// The server already put <App />'s HTML inside #root.
// Render the same tree, adopt the existing DOM, attach handlers.
hydrateRoot(document.getElementById('root')!, <App />)- SSR
- Rendering components to HTML on the server for each request (or at build time, for static generation) so the browser can paint before any JavaScript runs.
- Hydration
- Making server-rendered HTML interactive by re-running the components on the client and attaching to the existing DOM nodes.
- Hydration mismatch
- The client’s first render differs from the server HTML, so the framework cannot line its tree up with the DOM.
- Island
- An interactive component that is hydrated on its own inside an otherwise static HTML page.
- Resumability
- Serialising state and event bindings into the HTML so the client can continue without re-running components at all (Qwik).
Why it matters
Hydration bugs are the classic "works on my machine" of frontend work. A timestamp formatted in the server's UTC timezone and the user's local one, a random ID, or a browser extension that injects a node is enough to break it, often only in production or only for some users. The cost is not just a console error: the framework discards the server HTML for the affected subtree and renders it again, which flickers, loses the performance benefit of SSR there, and can reset inputs. Hydration itself is expensive too: until it finishes, the page looks ready but clicks do nothing, which shows up directly in INP (Interaction to Next Paint).
What hydration actually does
The browser shows server HTML within one round trip, but every button is inert until the framework's JavaScript has downloaded, parsed and run. Hydration then renders the component tree once more and, instead of creating DOM nodes, walks the existing ones in order, checking that each matches what the component would have produced and attaching listeners, refs and state to it. The gap between "looks ready" and "is ready" is sometimes called the uncanny valley.
- 1The server renders the tree to HTML and, in frameworks like Next.js, embeds the data the components used (props, loader data, the RSC payload) so the client can render the same thing without refetching.
- 2The browser parses and paints the HTML. Native features work already: links navigate, a plain
<form>can still submit to itsaction. - 3The JavaScript bundle loads and
hydrateRootrenders the tree. For each element it expects, React takes the next DOM node in document order and checks type, text and attributes. - 4On a match it attaches the fiber (React's internal node) to the DOM node and registers the event handlers. Effects (
useEffect) run after this pass, which is why they are the safe place for browser-only code. - 5On a mismatch it cannot trust the DOM below that point, reports an error and renders that subtree from scratch on the client, replacing the server HTML.
Why server and client disagree
Every mismatch has the same root cause: the first client render read something the server did not see, or the DOM the browser built is not the DOM the server wrote.
| Cause | Example | Fix |
|---|---|---|
| Time and randomness | Date.now(), Math.random(), generated IDs | Pass the value from the server, use useId() for IDs, update after mount |
| Locale and timezone | Server in UTC formats 23:30, client in Kyiv formats 02:30 the next day | Format with an explicit timeZone, or format on the client after mount |
| Browser-only branches | typeof window, localStorage, viewport width in render | Read them in an effect or with useSyncExternalStore and a server snapshot |
| Invalid HTML | <div> inside <p>, <a> inside <a>, a table without <tbody> | Fix the nesting; the HTML parser silently restructures it |
| Outside edits | Extensions, password managers, auto-translate, injected ads | Keep them out of app-owned DOM; mark untranslatable regions |
export function LastSeen({at}: {at: string}) {
// server (UTC): "Oct 6, 11:30 PM"
// client (Kyiv): "Oct 7, 2:30 AM" -> mismatch
return <span>{new Date(at).toLocaleString()}</span>
}'use client'
import {useEffect, useState} from 'react'
export function LastSeen({at}: {at: string}) {
// first render = server render: a stable, tz-free value
const [text, setText] = useState(at.slice(0, 10))
useEffect(() => {
// after hydration: local time is safe to show
setText(new Date(at).toLocaleString())
}, [at])
return <time dateTime={at}>{text}</time>
}The HTML parser rewrites invalid nesting
Rendering <p><div>…</div></p> on the server produces a string, but the browser's HTML parser is not allowed to put a block element inside a paragraph, so it closes the <p> early and builds a different DOM. React then finds nodes in places it did not expect. React DOM warns about invalid nesting in development, and the fix is always in the markup, never in suppressing the warning.
What React does with a mismatch
React treats a mismatch in text or structure as a recoverable error: it reports it (through onRecoverableError and the console), discards the server HTML up to the nearest Suspense boundary, or the whole root if there is none, and renders that part on the client. The page keeps working, but that region pays for SSR twice and may flash. Mismatched attributes are different: React does not patch them during hydration, so a wrong className from the server can silently stay in production. React 19 replaced the earlier separate warnings with one error that prints a diff of what the server sent and what the client expected, which makes the cause much easier to find.
For values that are meant to differ, there are two honest tools. A single element whose text legitimately differs, such as a timestamp, can opt out with suppressHydrationWarning; it only applies to that element's own text and attributes, one level deep, not to its children. For values that come from the browser, useSyncExternalStore lets you give React a server snapshot for hydration and switch to the real value right after.
import {useSyncExternalStore} from 'react'
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
cb => {
const mql = window.matchMedia(query)
mql.addEventListener('change', cb)
return () => mql.removeEventListener('change', cb)
},
() => window.matchMedia(query).matches, // client snapshot
() => false, // server + hydration snapshot: must match SSR
)
}Hydrating less
Hydration costs grow with the amount of interactive JavaScript, and on a slow phone several hundred milliseconds of hydration work blocks the main thread exactly when the user starts to interact. The modern answers all shrink what has to hydrate, or when.
| Approach | What hydrates | Where you see it |
|---|---|---|
| Full hydration | The whole tree, top to bottom, in one task | Classic SSR before React 18, many SPAs with SSR |
| Selective | Each Suspense boundary on its own, in priority order; user input jumps the queue | React 18+ with streaming SSR |
| Islands | Only components marked interactive, each on its own trigger (client:visible, client:idle) | Astro, Fresh, Eleventy with islands plugins |
| Server Components | Only client components; server components never ship JavaScript | Next.js App Router, React Router RSC |
| Resumability | Nothing up front; handler code loads on first interaction | Qwik |
Pitfalls
- Branching on typeof window during render
The server always takes the "no window" branch and the client the other, so the first client render differs by construction. Render the server version first and switch in an effect, or use
useSyncExternalStorewith a server snapshot. - Formatting dates and numbers without a fixed locale and timezone
Servers usually run in UTC with an English locale; users do not. Anything from
toLocaleString()toIntl.NumberFormatwithout explicit options can differ, and it only shows up for users in other regions, so local testing never catches it. - Silencing the warning instead of fixing the cause
suppressHydrationWarningon a wrapper does not cover its children, and on the right element it hides a real difference that React will not patch for attributes. Use it only for a single value that is meant to differ, like a timestamp. - Marking whole pages as client components
'use client'at the top of a layout pulls the entire subtree into the bundle and into hydration. The page still renders on the server, but now every byte of it must hydrate, which is where slow INP on content pages usually comes from. - Letting third parties edit app-owned DOM
Ad scripts, A/B testing snippets and translation tools that modify nodes React rendered cause mismatches before hydration and crashes after it. Give them their own container React never renders into, and mark dynamic regions
translate="no"where translation would break updates.
Interview questions
Q1What is hydration?
Hydration is how a server-rendered page becomes interactive: the client runs the same components again and attaches event handlers and state to the DOM the server produced, instead of building new DOM. It lets users see content before JavaScript runs, at the cost of shipping and executing that JavaScript anyway before the page responds to input.
Q2What happens when the server HTML and the first client render do not match?
React reports a recoverable hydration error and re-renders the affected subtree on the client, up to the nearest Suspense boundary or the whole root, replacing the server HTML there. The page works, but that region pays for rendering twice and may flicker or lose input state. Attribute differences are not patched at all, so they can persist silently in production.
Q3Walk me through rendering a value that is only known in the browser, like the user’s timezone.
The first client render must equal the server render, so I render a stable placeholder or a timezone-independent value on both sides, then update it in an effect after hydration. For subscriptions such as a media query I use useSyncExternalStore with a server snapshot. If the server can know the value, for example from a cookie, I pass it down so both renders agree from the start.
Q4Name common causes of hydration mismatches.
Non-deterministic values such as Date.now or Math.random, locale and timezone formatting, branches on typeof window or localStorage, invalid HTML nesting that the parser rewrites, and third parties editing the DOM, such as extensions or auto-translate. All of them come down to the client's first render seeing something the server did not, or the DOM differing from the HTML string.
Q5What happens if a user clicks a button before hydration finishes?
The DOM exists but has no handler yet, so in a plain setup the click does nothing. React 18 and later listen at the root early: if the click lands in a Suspense boundary that is not hydrated yet, React hydrates that boundary first and replays the event. Native behaviour still works, so a real form with an action submits even before any JavaScript runs.
Q6How do islands and React Server Components reduce hydration cost?
Both stop hydrating the static parts of the page. Islands hydrate only components explicitly marked interactive, each on its own trigger such as becoming visible. Server Components never send their code to the client at all, so only client components hydrate. Qwik goes further with resumability, serialising enough state that nothing runs until the user interacts.
Q7Our product page has good LCP but poor INP. Where would you look?
At hydration and other main-thread work right after load. A page can paint fast from SSR and still be busy hydrating a large client tree when the user first taps, so the interaction waits. I would profile the load, shrink what is marked as client code, split it with Suspense boundaries so hydration happens in smaller chunks, and defer non-critical widgets.
- Hydration re-runs components on the client and adopts the server's DOM; the page paints early but responds only after it finishes.
- The first client render must match the server HTML exactly. Anything time-, locale- or browser-dependent belongs in an effect or a server snapshot.
- A mismatch makes React discard server HTML up to the nearest Suspense boundary and render it again; attribute differences are not fixed at all.
- Invalid nesting, extensions and auto-translate change the DOM behind React's back. Fix the markup and keep third parties out of app-owned nodes.
- suppressHydrationWarning is for one element whose text is meant to differ, not a way to hide bugs.
- Hydrate less: selective hydration with Suspense, islands, Server Components with small client leaves, or resumability.