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 URLProgressive / 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.