Skip to content

How Hydration Works

advanced15 min read

Dry HTML, Add Water

Here is the best mental model for hydration: the server sends dry HTML — it looks right, has all the content, but it's completely inert. No click handlers, no state, no interactivity. Hydration is the process of adding water (JavaScript) to that dry HTML, bringing it to life.

Mental Model

Imagine you order furniture from IKEA. It arrives as a fully assembled display model (server HTML) — looks perfect, right shape, right color. But it's bolted to a shipping pallet and none of the drawers actually open. Hydration is when the delivery team unbolt it, attach the drawer slides, connect the soft-close hinges, and make everything actually functional. The furniture looked complete before, but now it works.

The technical reality is more nuanced than "just attach event handlers." Let's dig into what React actually does during hydration.

The Hydration Algorithm

When React hydrates, it doesn't throw away the server HTML and re-render from scratch. That would defeat the entire purpose. Instead, React walks the existing DOM and tries to adopt it.

Here's the step-by-step process:

Execution Trace
Step 1
Browser receives server HTML and parses it into DOM
User sees content immediately — no JS needed for visual
Step 2
React JS bundle downloads and executes
This can take seconds on slow connections
Step 3
React calls hydrateRoot() instead of createRoot()
This tells React: 'DOM already exists, adopt it'
Step 4
React renders the component tree virtually (in memory)
Same components run again on the client, producing a virtual DOM
Step 5
React walks the existing DOM and virtual DOM simultaneously
Comparing node-by-node: does the server HTML match what the client would render?
Step 6
React attaches event listeners to existing DOM nodes
onClick, onChange, onSubmit, etc. — now the page is interactive
Step 7
React sets up internal fiber tree from the existing DOM
State, refs, effects — React's internal bookkeeping is initialized
Step 8
useEffect callbacks fire, state becomes reactive
Page is now fully interactive — hydration complete

The critical insight: React re-runs your entire component tree on the client during hydration. Every component, every hook, every calculation. It doesn't just attach event handlers — it rebuilds its internal representation of the UI and verifies it matches what the server produced.

import { hydrateRoot } from 'react-dom/client'

hydrateRoot(
  document.getElementById('root'),
  <App />
)

The hydrateRoot API tells React: "There's already HTML here. Don't create new DOM — adopt what exists and make it interactive."

Quiz
During hydration, does React create new DOM elements?

Event Delegation Under the Hood

React doesn't attach individual event handlers to every element. Instead, it uses event delegation — a single listener on the root DOM node captures all events via bubbling.

function ProductCard({ id }) {
  return (
    <div onClick={() => track('view', id)}>
      <h2 onClick={() => navigate(`/product/${id}`)}>Product Name</h2>
      <button onClick={() => addToCart(id)}>Add to Cart</button>
    </div>
  )
}

React doesn't attach three separate click handlers. It attaches one listener on the root container. When a click event bubbles up, React's event system figures out which component should handle it based on the fiber tree.

During hydration, React sets up this delegation system and maps DOM nodes to their corresponding fibers. This is how a click on the button knows to call addToCart — the fiber tree connects DOM nodes to their handlers.

Why event delegation matters for hydration

Event delegation means React only needs to set up a handful of listeners on the root, regardless of how many interactive elements exist. A page with 1000 buttons doesn't need 1000 listeners — it needs one. This makes the event attachment phase of hydration nearly instant. The expensive part is re-running all the component logic to build the fiber tree.

The True Cost of Hydration

Hydration is expensive, and most developers underestimate why. It's not just "downloading JS." There are three distinct costs:

1. Download cost — The JavaScript for every component on the page must be downloaded. Server Components can't be hydrated (they don't ship JS), but every Client Component in the tree adds to the bundle.

2. Parse and compile cost — The browser's JS engine must parse and compile all that code. On a mid-range Android phone, parsing 1MB of JavaScript takes 2-3 seconds.

3. Execution cost — React re-runs every component function, every hook, every derived computation. If your component tree is 500 components deep, that's 500 function calls plus all their hooks.

Component JS download:    800ms (on 3G)
Parse + compile:          400ms (on mid-range mobile)
Component re-execution:   200ms (500 components)
Event handler setup:       10ms
─────────────────────────────────
Total hydration time:    1,410ms

During this entire window, the page looks loaded but nothing works. Buttons don't respond, forms don't submit, links don't navigate. This is the "uncanny valley" of web performance — the page looks ready, but it's lying to you.

Common Trap

A page can have a great Largest Contentful Paint (LCP) score but terrible Interaction to Next Paint (INP) because of hydration. The server HTML paints fast, but the page isn't actually interactive until hydration completes. Users click buttons that do nothing, creating frustration. Always measure Time to Interactive, not just visual metrics.

Quiz
A server-rendered page has 100 Client Components. During hydration, how many of these components re-execute on the client?

Selective Hydration (React 18+)

React 18 introduced selective hydration — instead of hydrating the entire page in one synchronous block, React prioritizes interactive parts based on user behavior. React 19 refined this further with improved scheduling and priority handling.

import { Suspense } from 'react'

function Page() {
  return (
    <main>
      <NavBar />
      <Suspense fallback={<HeroSkeleton />}>
        <Hero />
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
      <Footer />
    </main>
  )
}

Each Suspense boundary creates a hydration boundary. React hydrates them independently, and here's the magic: if the user clicks on an unhydrated section, React prioritizes hydrating that section first.

1. Browser displays server HTML (everything visible)
2. React starts hydrating NavBar (top of page, high priority)
3. User clicks on Comments section
4. React INTERRUPTS NavBar hydration
5. React hydrates Comments immediately (user interaction = highest priority)
6. Comments become interactive — click handler fires
7. React resumes hydrating NavBar, then Hero, then Footer

This is concurrent rendering applied to hydration. React's scheduler detects discrete user events (clicks, key presses) and bumps the hydration priority of the Suspense boundary containing the event target.

Quiz
With selective hydration, what happens when a user clicks a button inside an unhydrated Suspense boundary?

Hydration vs Full Client Render

Why not just skip hydration and do a full client render? Because creating DOM elements is expensive.

Full client render (createRoot):
  1. Download JS
  2. Execute components → build virtual DOM
  3. Create ALL DOM elements from scratch
  4. Attach event handlers
  Time: ~2000ms for large page

Hydration (hydrateRoot):
  1. Browser already parsed server HTML into DOM (free!)
  2. Download JS
  3. Execute components → build virtual DOM
  4. Walk existing DOM, adopt nodes
  5. Attach event handlers
  Time: ~1400ms for same page

Hydration saves the DOM creation step. On a page with thousands of elements, that's significant. But the JS download and execution costs are identical — which is why the biggest hydration optimization is shipping less JavaScript (React Server Components).

What developers doWhat they should do
Think hydration only attaches click handlers
React needs its full internal representation to manage updates. Event handlers are just the visible tip of what hydration does.
Hydration re-executes ALL component logic, builds the fiber tree, initializes state and effects
Assume the page is interactive as soon as content is visible
Server HTML is inert — it looks correct but no JavaScript handlers are attached. There's a gap between FCP and TTI.
The page is interactive only after hydration completes (or selective hydration processes that region)
Try to optimize hydration by reducing DOM elements
DOM walking is cheap. The expensive parts are JS download, parsing, and component re-execution. Ship less JS.
Reduce JavaScript bundle size and use Server Components to skip hydration entirely
Wrap every component in Suspense for selective hydration
Too many boundaries add overhead. Place them at natural content sections that can load independently.
Use Suspense boundaries at meaningful UI sections (nav, content, sidebar, comments)
Key Rules
  1. 1Hydration adopts existing server HTML — it does not recreate the DOM.
  2. 2React re-executes ALL client components during hydration to build its fiber tree.
  3. 3The hydration cost is JS download + parse + execution, not DOM creation.
  4. 4Selective hydration (React 18+) prioritizes user-interacted Suspense boundaries.
  5. 5The gap between visible content and working interactivity is the 'uncanny valley' — measure TTI, not just LCP.
  6. 6The best hydration optimization is shipping less JS — use React Server Components.