System Design & Architecture
Part 0 of 9Hydration Is a Performance Bug You Shipped on Purpose
Hydration Is a Performance Bug You Shipped on Purpose
Why hydration is architecturally expensive, partial hydration vs progressive hydration vs islands architecture, and how Next.js App Router is trying to solve what we've been ignoring for years.
The Uncomfortable Truth
You SSR your React app for performance. The HTML arrives fast. Users see content. Great.
Then the JavaScript loads. React "hydrates" the page — re-executing every component, rebuilding the entire tree in memory, attaching event handlers, and comparing its work against the HTML that's already there.
You just ran your components twice. Once on the server, once on the client. And until hydration completes, the page looks interactive but isn't. Buttons don't work. Forms don't submit. Links might not navigate.
This is hydration. It's not a feature. It's an architectural debt we've been paying for a decade.
THE HYDRATION TAX
────────────────────────────────────────────────────────────────────
Server Browser
─────── ───────
1. Request arrives
2. Run ALL components 3. Download HTML
3. Generate HTML string 4. Paint (users see content)
4. Send HTML 5. Download JavaScript
6. Parse JavaScript
7. Run ALL components AGAIN
8. Build virtual DOM
9. Compare with real DOM
10. Attach event handlers
11. NOW the page works
Time to Interactive (TTI) = step 11
Everything before that is FAKE interactivity
Why This Matters
The Metrics
TYPICAL E-COMMERCE SITE:
────────────────────────────────────────────────────────────────────
First Contentful Paint (FCP): 1.2 seconds ✓
Time to Interactive (TTI): 4.8 seconds ✗
Total Blocking Time (TBT): 1,200ms ✗
JavaScript Payload: 450KB
Hydration Time: 2.1 seconds
The user sees the page at 1.2s.
The user can USE the page at 4.8s.
That's a 3.6 second lie.
The User Experience
USER JOURNEY DURING HYDRATION:
────────────────────────────────────────────────────────────────────
t=0.0s User requests page
t=0.8s HTML arrives, page paints
User: "Great, the site loaded!"
t=1.2s User clicks "Add to Cart"
Nothing happens.
User: "...?"
t=1.5s User clicks again
Nothing happens.
User: "Is this broken?"
t=2.0s User clicks third time
Still nothing.
User: *frustration*
t=2.3s Hydration completes
All three clicks fire
THREE items added to cart
User: *rage closes tab*
This isn't a theoretical problem. It's why rage clicks are a thing. It's why e-commerce sites lose conversions. It's why users think your site is broken.
The Architecture Problem
What Hydration Actually Does
// Simplified React hydration flow
function hydrate(component: React.Component, container: HTMLElement) {
// 1. React doesn't trust the HTML that's already there
// Even though the server JUST generated it
// 2. React re-renders the entire tree in memory
const virtualDOM = render(component);
// 3. React walks the existing DOM
// Comparing every node to what it computed
// 4. React attaches event handlers
// This is the ONLY part that actually needs to happen
// 5. If anything doesn't match (hydration mismatch)
// React throws away the HTML and re-renders
// Worst case scenario
}
Why It's Expensive
HYDRATION COST BREAKDOWN:
────────────────────────────────────────────────────────────────────
1. JAVASCRIPT DOWNLOAD
└── All component code must be downloaded
└── Even for components that will never re-render
└── Even for static content that has no interactivity
2. JAVASCRIPT PARSE
└── Browser must parse all the code
└── Blocks main thread
└── Larger bundles = longer blocking
3. COMPONENT EXECUTION
└── Every component function runs
└── Every hook executes
└── Every computation happens
└── useState initializers run
└── useEffect setup functions run (after paint)
4. TREE RECONCILIATION
└── React builds virtual DOM
└── React compares to real DOM
└── O(n) where n = total DOM nodes
└── For complex pages, this is thousands of nodes
5. EVENT ATTACHMENT
└── The only NECESSARY part
└── Typically <5% of hydration time
└── Everything else is overhead
The Fundamental Flaw
THE PROBLEM IN ONE SENTENCE:
────────────────────────────────────────────────────────────────────
React can't know which parts of your page are interactive
without running ALL the JavaScript first.
A static header? React runs it.
A footer with no events? React runs it.
Marketing copy that never changes? React runs it.
The framework treats everything as potentially dynamic,
so everything pays the dynamic tax.
The Solutions Spectrum
┌─────────────────────────────────────────────────────────────────┐
│ HYDRATION STRATEGIES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ FULL HYDRATION (Traditional) │
│ └── Hydrate everything, pay for everything │
│ │
│ PROGRESSIVE HYDRATION │
│ └── Hydrate in chunks, prioritize visible/interactive │
│ │
│ PARTIAL HYDRATION │
│ └── Only hydrate interactive parts, skip the rest │
│ │
│ ISLANDS ARCHITECTURE │
│ └── Isolated interactive components in a static sea │
│ │
│ RESUMABILITY │
│ └── Don't re-execute, resume from serialized state │
│ │
│ SERVER COMPONENTS │
│ └── Some components never ship to client at all │
│ │
└─────────────────────────────────────────────────────────────────┘
Less JavaScript ──────────────────────► More JavaScript
Better Performance ──────────────────► Worse Performance
Less Flexibility ──────────────────► More Flexibility
Progressive Hydration
The idea: hydrate in priority order. Visible content first. Below-the-fold later. Off-screen never (until needed).
PROGRESSIVE HYDRATION FLOW:
────────────────────────────────────────────────────────────────────
Initial paint:
┌─────────────────────────────────────────┐
│ Header [Not hydrated]│
├─────────────────────────────────────────┤
│ │
│ Hero Section [Hydrating...] │ ← Priority 1
│ with CTA Button │
│ │
├─────────────────────────────────────────┤
│ │
│ Product Grid [Not hydrated]│ ← Priority 2
│ │
├─────────────────────────────────────────┤
│ Below fold... [Not hydrated]│ ← Priority 3 (on scroll)
└─────────────────────────────────────────┘
Hydration order:
1. Hero (visible, interactive)
2. Product grid (visible, needs events)
3. Header (visible but less critical)
4. Below fold (on scroll into view)
Implementation
// Progressive hydration with React 18
import { lazy, Suspense } from 'react';
// Lazy load below-the-fold content
const BelowTheFold = lazy(() => import('./BelowTheFold'));
function Page() {
return (
<>
{/* Hydrates immediately */}
<Header />
<HeroSection />
{/* Hydrates when visible */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Hydrates when scrolled into view */}
<LazyHydrate whenVisible>
<BelowTheFold />
</LazyHydrate>
</>
);
}
// Custom LazyHydrate component
function LazyHydrate({
children,
whenVisible = false,
whenIdle = false,
}: {
children: React.ReactNode;
whenVisible?: boolean;
whenIdle?: boolean;
}) {
const [hydrated, setHydrated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (whenIdle) {
// Hydrate during idle time
if ('requestIdleCallback' in window) {
requestIdleCallback(() => setHydrated(true));
} else {
setTimeout(() => setHydrated(true), 200);
}
return;
}
if (whenVisible && ref.current) {
// Hydrate when scrolled into view
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHydrated(true);
observer.disconnect();
}
},
{ rootMargin: '100px' }
);
observer.observe(ref.current);
return () => observer.disconnect();
}
// Hydrate immediately
setHydrated(true);
}, [whenVisible, whenIdle]);
if (!hydrated) {
// Return the SSR HTML without hydrating
return (
<div
ref={ref}
dangerouslySetInnerHTML={{ __html: '' }}
suppressHydrationWarning
/>
);
}
return <>{children}</>;
}
Limitations
PROGRESSIVE HYDRATION PROBLEMS:
────────────────────────────────────────────────────────────────────
1. STILL DOWNLOADS ALL JAVASCRIPT
└── Just delays execution
└── Bundle size unchanged
2. HYDRATION ORDER COMPLEXITY
└── What if user clicks unhydrated area?
└── Parent must hydrate before child
└── Dependencies get complicated
3. DOESN'T REDUCE TOTAL WORK
└── Just spreads it out
└── Total CPU time unchanged
4. FRAMEWORK SUPPORT
└── Not built into React
└── Third-party solutions have quirks
└── Easy to break SSR
Partial Hydration
The idea: don't hydrate static content at all. Only hydrate components that have interactivity.
PARTIAL HYDRATION VISUALIZATION:
────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────┐
│ Header [STATIC] │ ← No JS shipped
├─────────────────────────────────────────┤
│ │
│ Hero Section [STATIC] │ ← No JS shipped
│ │
│ ┌─────────────────────────────────┐ │
│ │ "Add to Cart" Button [HYDRATE] │ │ ← JS for this only
│ └─────────────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ Product Description [STATIC] │ ← No JS shipped
│ (Marketing copy) │
├─────────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ Review Form [HYDRATE] │ │ ← JS for this only
│ └─────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Footer [STATIC] │ ← No JS shipped
└─────────────────────────────────────────┘
JavaScript shipped: Only for 2 components
JavaScript NOT shipped: Header, Hero, Description, Footer
The Challenge
React wasn't designed for this. You can't easily say "this component doesn't need hydration."
// THE PROBLEM:
// This component has no interactivity
function ProductDescription({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
// But React has no way to know that.
// The component function, its dependencies, and React's
// reconciliation code all ship to the client.
// Even if we KNOW it's static, React's architecture
// requires it to be re-executed for hydration.
Astro's Solution
Astro was built around partial hydration from the start:
---
// ProductPage.astro
import Header from './Header.astro'; // Static, no JS
import Hero from './Hero.astro'; // Static, no JS
import AddToCart from './AddToCart.jsx'; // Interactive, hydrated
import Footer from './Footer.astro'; // Static, no JS
---
<html>
<body>
<Header />
<Hero product={product} />
<!-- Only this component ships JavaScript -->
<AddToCart client:visible productId={product.id} />
<Footer />
</body>
</html>
<!--
Result:
- Most of the page is pure HTML/CSS
- AddToCart.jsx loads ONLY when visible
- Total JS: ~5KB instead of ~150KB
-->
Islands Architecture
Islands takes partial hydration further: isolated interactive components ("islands") in a sea of static HTML.
ISLANDS ARCHITECTURE:
────────────────────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────────────────┐
│ │
│ STATIC HTML OCEAN │
│ │
│ ┌─────────────┐ │
│ │ ISLAND │ │
│ │ (Search) │ ← Hydrated independently │
│ └─────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ ISLAND │ │
│ │ (Product Carousel) │ ← Hydrated independently │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ ISLAND │ │
│ │ (Add Cart) │ ← Hydrated independently │
│ └─────────────┘ │
│ │
│ STATIC HTML OCEAN │
│ │
└─────────────────────────────────────────────────────────────────┘
Key insight: Islands don't share state by default.
They're isolated. This is the tradeoff.
How Islands Work
// islands/SearchBar.tsx
// In Astro, Fresh (Deno), or similar frameworks
import { useState } from 'react';
// This entire component is an "island"
// It hydrates independently of everything else
export default function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch(`/api/search?q=${query}`);
setResults(await res.json());
};
return (
<form onSubmit={handleSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</form>
);
}
// Usage in page:
// <SearchBar client:visible />
//
// - Zero JavaScript sent for the rest of the page
// - SearchBar loads only when scrolled into view
// - SearchBar hydrates in isolation
The Tradeoff
ISLANDS: THE GOOD
────────────────────────────────────────────────────────────────────
✓ Dramatically less JavaScript
✓ Each island hydrates independently
✓ Failure in one island doesn't break others
✓ Fine-grained loading strategies
✓ Static content is truly static
ISLANDS: THE HARD
────────────────────────────────────────────────────────────────────
✗ No shared state between islands (by design)
✗ Cross-island communication is manual
✗ Can't lift state to parent (parent isn't hydrated)
✗ Requires rethinking component composition
✗ Some React patterns don't work
EXAMPLE OF THE PROBLEM:
// In traditional React, this is easy:
function Page() {
const [cart, setCart] = useState([]);
return (
<>
<Header cartCount={cart.length} />
<ProductList onAdd={(p) => setCart([...cart, p])} />
<CartSidebar items={cart} />
</>
);
}
// In Islands, Header, ProductList, and CartSidebar
// are separate islands. They can't share cart state
// through React. You need external state (store, URL, etc.)
React Server Components: The Next Evolution
RSC is React's answer to partial hydration — but it works differently than islands.
The Key Insight
TRADITIONAL SSR:
────────────────────────────────────────────────────────────────────
Server: Render all components → HTML
Client: Download all component code → Hydrate all components
Everything runs twice. All code ships.
SERVER COMPONENTS:
────────────────────────────────────────────────────────────────────
Server: Render Server Components → Output (not code)
Client Components → Marked for hydration
Client: Receive output + Client Component code
Hydrate ONLY Client Components
Server Components run ONCE, on server.
Their code NEVER ships to client.
How It Works in Next.js App Router
// app/products/[id]/page.tsx
// This is a SERVER COMPONENT by default
import { db } from '@/lib/database';
import { ProductInfo } from './ProductInfo';
import { AddToCartButton } from './AddToCartButton';
import { ReviewSection } from './ReviewSection';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
// This runs ONLY on the server
// db can be a direct database connection
const product = await db.products.findUnique({
where: { id: params.id },
include: { reviews: true },
});
// ProductInfo is a Server Component - no JS ships
// AddToCartButton is a Client Component - JS ships
return (
<div>
<ProductInfo product={product} />
<AddToCartButton productId={product.id} />
<ReviewSection reviews={product.reviews} />
</div>
);
}
// ProductInfo.tsx (Server Component)
// No 'use client' directive
export function ProductInfo({ product }) {
// This code NEVER runs on client
// This component NEVER hydrates
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
</div>
);
}
// AddToCartButton.tsx (Client Component)
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
// This code runs on client
// This component hydrates
const handleClick = async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
};
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
);
}
What Ships to the Browser
SERVER COMPONENT OUTPUT:
────────────────────────────────────────────────────────────────────
What the server sends:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. RSC PAYLOAD (serialized output of Server Components) │
│ { │
│ "type": "div", │
│ "children": [ │
│ { "type": "h1", "children": "Product Name" }, │
│ { "type": "p", "children": "Description..." }, │
│ { "$ref": "AddToCartButton", "props": { "id": "123" }} │
│ ] │
│ } │
│ │
│ 2. CLIENT COMPONENT CODE │
│ - AddToCartButton.js │
│ - Its dependencies │
│ │
│ NOT SENT: │
│ - ProductInfo.js (never existed on client) │
│ - Server-only dependencies (database, etc.) │
│ - React code for Server Components │
│ │
└─────────────────────────────────────────────────────────────────┘
The hydration boundary is at 'use client'.
Everything above doesn't hydrate because it's not on the client.
Server Components vs Islands
┌─────────────────────────────────────────────────────────────────┐
│ RSC vs ISLANDS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SERVER COMPONENTS │ ISLANDS │
│ ──────────────────────────────────────┼────────────────────── │
│ Composition Nested freely │ Separate roots │
│ State sharing Through props │ External only │
│ Server data Direct access │ Via API/props │
│ Mental model Components │ Components + Pages │
│ Refactoring Easier │ More planning │
│ Bundle splitting Automatic │ Manual boundaries │
│ Framework React/Next.js │ Astro, Fresh, etc. │
│ │
│ RSC: Server Components can render Client Components │
│ Client Components CAN'T import Server Components │
│ But can receive them as children │
│ │
│ Islands: Each island is isolated │
│ No nesting of hydrated components │
│ Communication through external means │
│ │
└─────────────────────────────────────────────────────────────────┘
Resumability: Qwik's Radical Approach
Qwik (from the Builder.io team) asks: what if we didn't hydrate at all?
The Core Idea
TRADITIONAL HYDRATION:
────────────────────────────────────────────────────────────────────
Server:
1. Run components
2. Serialize HTML
Client:
3. Download code
4. Run components AGAIN (hydration)
5. Rebuild state
6. Attach handlers
QWIK RESUMABILITY:
────────────────────────────────────────────────────────────────────
Server:
1. Run components
2. Serialize HTML
3. Serialize state + event handlers into HTML
Client:
3. Nothing! (until interaction)
On first click:
4. Download ONLY the handler for that click
5. Execute it
6. Continue from where server left off
No re-execution. No rebuilding. Resume.
How Qwik Works
// Qwik component
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
Count: {count.value}
</button>
);
});
// The $ suffix marks lazy-loadable boundaries
// onClick$ means: "don't load this handler until clicked"
// Server output includes:
// - The HTML
// - Serialized state (count = 0)
// - Reference to click handler (not the code itself)
// On click:
// 1. Qwik's tiny runtime (~1KB) intercepts
// 2. Loads the click handler code on demand
// 3. Executes with serialized state
// 4. No hydration ever happens
The Tradeoffs
QWIK: THE GOOD
────────────────────────────────────────────────────────────────────
✓ Near-zero JavaScript on page load
✓ Instant interactivity (no hydration delay)
✓ Fine-grained code loading
✓ O(1) startup regardless of app size
✓ Serialized state survives page transitions
QWIK: THE CHALLENGES
────────────────────────────────────────────────────────────────────
✗ Different mental model (not React)
✗ The $ syntax takes getting used to
✗ Smaller ecosystem
✗ First interaction has code-loading latency
✗ Complex state requires careful serialization
✗ Not React (for teams committed to React)
Comparing Approaches
JavaScript Sent to Client
JAVASCRIPT PAYLOAD COMPARISON (same app):
────────────────────────────────────────────────────────────────────
Full Hydration (React SPA): 380 KB
Progressive Hydration (React): 380 KB (same, different timing)
Server Components (Next.js): 120 KB (only Client Components)
Islands (Astro + React): 45 KB (only islands)
Resumability (Qwik): 5 KB (runtime only, rest lazy)
Time to Interactive
TTI COMPARISON (3G connection, same app):
────────────────────────────────────────────────────────────────────
Full Hydration: 4.2 seconds
Progressive Hydration: 3.8 seconds (first interaction)
Server Components: 2.1 seconds
Islands: 1.4 seconds
Resumability: 0.8 seconds
Complexity
MIGRATION COMPLEXITY:
────────────────────────────────────────────────────────────────────
From React SPA to:
Progressive Hydration: Low (add library, wrap components)
Server Components: Medium (Next.js 13+, mark 'use client')
Islands: High (Astro rewrite, rethink state)
Resumability: Very High (Qwik rewrite, new framework)
Practical Recommendations
The Decision Tree
WHAT SHOULD YOU USE?
────────────────────────────────────────────────────────────────────
How interactive is your app?
│
┌───────────────────┼───────────────────┐
│ │ │
Mostly static Mixed content Highly interactive
(blog, docs) (e-commerce) (dashboard, app)
│ │ │
▼ ▼ ▼
Islands (Astro) Server Components Full React SPA
or Static Site (Next.js App Router) with code splitting
│
│
Performance critical?
│
┌─────────┴─────────┐
│ │
Yes No
│ │
▼ ▼
Islands for static Server Components
RSC for dynamic are probably fine
Migration Strategy
// STEP 1: Audit your components
// Categorize each component:
// - STATIC: No interactivity, no client state
// - INTERACTIVE: Has onClick, onChange, useState, etc.
// - MIXED: Static content + interactive elements
// STEP 2: In Next.js App Router, default is Server Component
// app/page.tsx (Server Component by default)
export default async function Page() {
const data = await fetchData(); // Server-only
return (
<div>
<StaticContent data={data} /> {/* Server Component */}
<InteractiveWidget /> {/* Client Component */}
</div>
);
}
// STEP 3: Only add 'use client' where needed
// components/StaticContent.tsx
// NO 'use client' - this stays on server
export function StaticContent({ data }) {
return <div>{data.content}</div>;
}
// components/InteractiveWidget.tsx
'use client'; // Only this becomes a Client Component
import { useState } from 'react';
export function InteractiveWidget() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Optimizing Existing React Apps
// If you're stuck with client-side React, optimize hydration:
// 1. Code split aggressively
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// 2. Defer non-critical hydration
function App() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Defer non-critical UI
requestIdleCallback(() => setMounted(true));
}, []);
return (
<>
<CriticalUI />
{mounted && <NonCriticalUI />}
</>
);
}
// 3. Use Suspense for gradual hydration
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
// 4. Minimize component tree depth
// Flatter trees hydrate faster
// 5. Avoid expensive initial renders
// Don't run heavy computations during first render
const data = useMemo(() => expensiveComputation(props), [props]);
Quick Reference
Hydration Strategies Compared
┌────────────────────────────────────────────────────────────────────────────┐
│ Strategy │ JS Size │ TTI │ Complexity │ Best For │
├───────────────────────┼─────────┼───────┼────────────┼────────────────────┤
│ Full Hydration │ High │ Slow │ Low │ SPAs │
│ Progressive Hydration │ High │ Medium│ Medium │ Content sites │
│ Partial Hydration │ Medium │ Fast │ Medium │ Mixed sites │
│ Islands │ Low │ Fast │ High │ Mostly static │
│ Server Components │ Medium │ Fast │ Medium │ Next.js apps │
│ Resumability │ Very Low│ Instant│ High │ Performance-critical│
└────────────────────────────────────────────────────────────────────────────┘
Framework Mapping
IF YOU USE: CONSIDER:
────────────────────────────────────────────────────────────────────
Create React App → Migrate to Next.js App Router (RSC)
Next.js Pages → Upgrade to App Router (RSC)
Gatsby → Astro (Islands) or Next.js (RSC)
Remix → Already optimized, add lazy loading
Vue → Nuxt 3 (similar to RSC approach)
Svelte → SvelteKit (already minimal hydration)
Static site → Astro (Islands)
New project → Start with Next.js App Router or Astro
Closing Thoughts
Hydration was a necessary evil when we wanted server rendering with client interactivity. Run components on the server, run them again on the client, hope they match. It worked, but it was never efficient.
The industry is finally moving past this. Server Components, Islands, and Resumability represent different solutions to the same realization: we shouldn't ship code for things that don't need to run on the client.
If you're starting a new project, use Next.js App Router with Server Components. You get the composition model of React without paying for hydration on static content.
If you're optimizing an existing app, audit your components. How many actually need interactivity? How much JavaScript are you shipping for content that never changes? The answer is usually "more than you'd like."
Hydration is a performance bug we shipped on purpose because we didn't have better options. Now we do.
What did you think?