Skip to content
yutils

How Hydration Actually Works

React attaching event handlers onto SSR HTML, the hydration mismatch error and what really triggers it, progressive / selective / partial hydration, island architecture (Astro / Qwik resumability), and why hydration is the cost of SSR's win.

~9 min read

SSR's fast first paint + SPA's interactivity combined. Not free — there's a hydration cost. React attaches event handlers onto the server-rendered HTML. Mismatch errors, progressive hydration, Astro / Qwik island architecture — this guide covers all of it.

Hydration — What It Does

SSR flow:
1. server: React tree → HTML string
2. browser: paints HTML instantly (user sees it, fast)
3. browser: downloads JS bundle
4. hydration:
   - map React fibers onto each element of server HTML
   - attach event handlers (onClick, etc.)
   - activate useState / useEffect
   - "now interactive"

Between 3 and 4 finishing:
   - UI is visible but clicks may not respond (or only partly)
   - The gap between "First Contentful Paint" (fast) and
     "Time to Interactive" (slower)

→ Hydration cost = the SSR trade-off.

Hydration Mismatch — The Most Common Error

function Greeting() {
  return <p>Today is {new Date().toLocaleDateString()}</p>;
}

server (KST timezone): "Today is 2026-05-26"
client (KST timezone): "Today is 2026-05-26"
                      → server rendered at 23:59 but client renders
                       after midnight → "2026-05-27" appears
                      → mismatch error

Error: Hydration failed because the initial UI does not match what
       was rendered on the server.

Causes:
1. Time (Date.now, new Date)
2. Random (Math.random, UUID)
3. Browser-only APIs (window, localStorage, navigator)
4. Per-user inputs (geolocation, cookies, IP-based)
5. Environment (server-only vs client-only env)

Mismatch Fix Patterns

Approach 1 — make the component client-only:
  // Next.js
  const ClientOnlyTime = dynamic(() => import("./Time"), {ssr: false});

Approach 2 — use dynamic values only inside useEffect:
  function Greeting() {
    const [date, setDate] = useState<string | null>(null);
    useEffect(() => {
      setDate(new Date().toLocaleDateString());
    }, []);
    return <p>Today is {date ?? "loading"}</p>;
  }
  // server render = "loading"
  // client render = "loading" (match!) → then setDate → "2026-05-26"

Approach 3 — suppressHydrationWarning (one element only):
  <p suppressHydrationWarning>
    {new Date().toLocaleDateString()}
  </p>
  // Silence mismatch warning, prefer client value
  // Only this element's children — not recursive

Approach 4 — ensure server / client agree:
  - Time: timestamp on server + format on client
  - Random: useId hook (React 18+)
  - Locale: consistent via cookie or URL

Progressive / Selective Hydration (React 18+)

Traditional hydration (React ≤ 17):
  Every component on the page hydrates at once
  → big page = big main-thread block
  → user interactions ignored during that time

React 18's selective hydration:
  Suspense boundaries are hydration units
  - Above-the-fold Suspense hydrates first
  - Below-the-fold is deferred until idle or visible
  - Click on below-the-fold → that boundary jumps priority → hydrated first

  <Suspense fallback={<Skel/>}>
    <Header />  ← 1st
  </Suspense>
  <Suspense fallback={<Skel/>}>
    <Sidebar />  ← 2nd (hydrate on idle)
  </Suspense>
  <Suspense fallback={<Skel/>}>
    <Comments />  ← 3rd (priority on click)
  </Suspense>

→ TTI tunable per component. Large pages can be partially instant.

Island Architecture (Astro)

Different base assumption:
  React/SSR: the whole page is a React app — hydrate everything
  Astro: page is static HTML — only "islands" are interactive

Astro code:
  ---
  // frontmatter (build-time only)
  const posts = await db.posts.findAll();
  ---
  <html>
    <body>
      <h1>{posts[0].title}</h1>   ← static HTML
      <p>{posts[0].body}</p>      ← static HTML
      <LikeButton client:load />  ← React/Vue island (hydrated)
      <CommentForm client:visible /> ← hydrate on intersection
    </body>
  </html>

→ ~95% static HTML + ~5% interactive islands → tiny JS shipped.
→ Great for blogs / marketing / news sites.

Qwik — Resumability (No Hydration)

Qwik (2022+) takes a different path:
  - SSR HTML embeds state + event handler references
  - Hydration step = 0 (immediately interactive)
  - Handler JS chunk fetched lazily on first click

  HTML: <button on:click="/chunks/handleClick">Like</button>
                        ↑
                  fetch + run only on click

Pros:
  - O(1) startup — independent of page size
  - Massive apps can be instantly interactive

Cons:
  - Mental model takes adjusting
  - Per-handler chunks → tiny-chunk round-trip cost
  - Smaller ecosystem

→ Tackles "hydration cost" at the root. Different path from React.

Common Pitfalls

  • Branching on typeof window in components — different server / client output → mismatch. Move to useEffect.
  • Dynamic values in useState initial — new Date() differs server vs client. Lazy init + useEffect.
  • Direct localStorage access — doesn't exist on the server. Use useEffect or useSyncExternalStore.
  • Hydrating the entire large page without RSC — slow TTI. Use Suspense boundaries or islands.
  • Overusing suppressHydrationWarning — hides real mismatches → silent UI corruption in production.

Wrap-up

Hydration is the SSR tax — React attaching to server HTML. Time / random / browser-only APIs are the mismatch culprits. Modern React 18 selective hydration + Astro islands + Qwik resumability all attack that cost from different angles.

Practical — use Next.js / Remix defaults + put dynamic values in useEffect + split big subtrees into Suspense boundaries. For mostly-static content, consider Astro islands.

Back to guides