| name | hydration-patterns |
| description | Explains hydration, partial hydration, progressive hydration, and islands architecture. Use when discussing how server-rendered HTML becomes interactive, optimizing JavaScript loading, or choosing between full hydration and islands architecture. |
Hydration Patterns
What is Hydration?
Hydration is the process of attaching JavaScript event handlers to server-rendered HTML, making static content interactive.
Server-Rendered HTML (static) → JavaScript Attaches → Interactive Application
"Dry HTML" → Hydration → "Hydrated App"
The Hydration Process
Step by Step
1. Server renders HTML with content
2. Browser displays HTML immediately (fast FCP)
3. Browser downloads JavaScript bundle
4. JavaScript parses and executes
5. Framework "hydrates":
- Walks the DOM
- Attaches event listeners
- Initializes state
- Makes components interactive
6. App is now fully interactive (TTI reached)
The Hydration Gap
The time between content visible (FCP) and interactive (TTI):
Timeline:
[------- Server Render -------][-- HTML Sent --][---- JS Download ----][-- Hydration --]
↓ ↓
Content Visible Interactive
[========= Hydration Gap =========]
(Page looks ready but isn't)
Hydration Challenges
1. Time to Interactive (TTI) Delay
Even though content is visible, buttons don't work until hydration completes.
User sees "Buy Now" button → Clicks → Nothing happens → Frustrated
(hydration not complete)
2. Hydration Mismatch
If client render differs from server render:
3. Bundle Size
Full hydration requires downloading ALL component code:
Page uses: Header, Hero, ProductList, Footer, Cart, Reviews...
Bundle includes: ALL components (even if user never interacts)
4. CPU Cost
Hydration walks the entire DOM and attaches listeners:
Large page = More DOM nodes = Longer hydration = Worse TTI
Full Hydration
Traditional approach: hydrate the entire page.
import { hydrateRoot } from 'react-dom/client';
import App from './App';
hydrateRoot(document.getElementById('root'), <App />);
Pros and Cons
| Pros | Cons |
|---|
| Simple mental model | Large JS bundles |
| Full interactivity everywhere | Slow TTI on large pages |
| Framework handles everything | Wasted JS for static content |
Progressive Hydration
Hydrate components in priority order, deferring non-critical parts.
Strategy
1. Hydrate above-the-fold content first
2. Hydrate below-the-fold on scroll or idle
3. Hydrate low-priority on user interaction
Implementation Pattern
function LazyHydrate({ children, whenVisible }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setHydrated(true);
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
if (!hydrated) return <div ref={ref}>{/* static HTML */}</div>;
return children;
}
Timeline
[Initial Hydration - Critical Components Only]
↓
[Scroll/Idle - Hydrate More Components]
↓
[Interaction - Hydrate On Demand]
Selective Hydration
Only hydrate components that need interactivity.
React 18+ with Suspense enables this:
<Suspense fallback={<Loading />}>
<Comments /> {}
</Suspense>
How It Works
Server streams: Header (hydrated) → Content (hydrated) → Comments (pending)
Comments data arrives → Comments hydrate independently
User clicks comment before hydration → React prioritizes that subtree
Partial Hydration
Ship zero JavaScript for static components.
The Insight
Most pages have static and interactive parts:
┌─────────────────────────────────────┐
│ Header (static) │ ← No JS needed
├─────────────────────────────────────┤
│ Hero Banner (static) │ ← No JS needed
├─────────────────────────────────────┤
│ Product List (static) │ ← No JS needed
├─────────────────────────────────────┤
│ Add to Cart Button ★ │ ← NEEDS JS
├─────────────────────────────────────┤
│ Reviews (static) │ ← No JS needed
├─────────────────────────────────────┤
│ Newsletter Signup ★ │ ← NEEDS JS
├─────────────────────────────────────┤
│ Footer (static) │ ← No JS needed
└─────────────────────────────────────┘
With partial hydration: Only "Add to Cart" and "Newsletter" components ship JS.
Islands Architecture
Independent interactive components in a sea of static HTML.
Concept
┌─────────────────────────────────────────────────┐
│ │
│ Static HTML (server-rendered, no JS) │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Island │ │ Island │ │
│ │ (Counter) │ │ (Search) │ │
│ │ [JS: 2KB] │ │ [JS: 5KB] │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Static content continues... │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Island (Comments) │ │
│ │ [JS: 8KB] │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
Total JS: 15KB (vs potentially 200KB+ for full hydration)
Key Characteristics
| Aspect | Full Hydration | Islands |
|---|
| JS shipped | All components | Only interactive |
| Hydration | Entire page | Per island |
| Bundle size | Large | Minimal |
| TTI | Slow | Fast |
| Coupling | Components connected | Islands independent |
Frameworks Using Islands
- Astro: First-class islands support
- Fresh (Deno): Islands by default
- Îles: Islands for Vite
- Qwik: Resumability (related concept)
Island Definition Example (Astro)
---
// Static by default
import Header from './Header.astro';
import Counter from './Counter.jsx'; // React component
---
<Header /> <!-- No JS shipped -->
<Counter client:load /> <!-- JS shipped, hydrates on load -->
<Counter client:visible /> <!-- JS shipped, hydrates when visible -->
<Counter client:idle /> <!-- JS shipped, hydrates on browser idle -->
Resumability (Qwik's Approach)
Skip hydration entirely by serializing application state.
Traditional Hydration
Server renders → Client downloads JS → Re-executes to rebuild state → Attaches listeners
Resumability
Server renders + serializes state → Client downloads minimal JS → Resumes exactly where server left off
Key Difference
<button on:click="./chunk.js#handleClick[0]">Click me</button>
Choosing a Hydration Strategy
| Scenario | Recommended Pattern |
|---|
| SPA/Dashboard | Full hydration |
| Content site with some interactivity | Islands |
| E-commerce product page | Partial/Progressive |
| Marketing landing page | Islands or SSG (no hydration) |
| Highly interactive app | Full hydration with code splitting |
| Performance critical, varied interactivity | Islands or Resumability |
Hydration Anti-Patterns
1. Hydration Mismatch
function Greeting() {
return <p>Hello at {new Date().toLocaleTimeString()}</p>;
}
function Greeting() {
const [time, setTime] = useState(null);
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <p>Hello{time && ` at ${time}`}</p>;
}
2. Blocking Hydration on Data
useEffect(() => {
fetchData().then(setData);
}, []);
3. Huge Hydration Bundles
import { FullCalendar } from 'massive-calendar-lib';
const Calendar = lazy(() => import('./Calendar'));
Deep Dive: Understanding Hydration From First Principles
Why Hydration Exists: The Core Problem
Hydration exists because of a fundamental mismatch between two goals:
GOAL 1: Fast First Paint (show content quickly)
→ Server rendering gives HTML immediately
→ Browser displays without JavaScript
GOAL 2: Rich Interactivity (React/Vue/Svelte apps)
→ Requires JavaScript
→ Event handlers, state, reactivity
THE CONFLICT:
Server-rendered HTML is STATIC
Your framework needs to MANAGE that HTML
THE SOLUTION:
"Hydration" - attach framework to existing HTML
What Hydration Actually Does Internally
Let's trace through React's hydration process step by step:
`<button id="counter">Count: 0</button>`
hydrateRoot(document.getElementById('root'), <App />);
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
const existingButton = document.querySelector('button');
existingButton.addEventListener('click', () => setCount(c => c + 1));
The key insight: Hydration doesn't recreate the DOM. It walks the existing DOM and "wires up" the JavaScript.
The Hydration Mismatch Problem in Detail
When server HTML doesn't match what client would render:
function NewYearCountdown() {
const now = new Date();
return <p>Current time: 11:59:59 PM</p>;
}
function NewYearCountdown() {
const now = new Date();
return <p>Current time: 12:00:01 AM</p>;
}
What happens on mismatch:
DEVELOPMENT MODE:
- Console warning: "Text content did not match"
- React shows the client version (visual jump)
PRODUCTION MODE:
- React silently patches to client version
- Can cause visual flicker
- SEO mismatch if crawled during gap
SEVERE MISMATCH:
- Different DOM structure, not just text
- React may throw errors
- App may break completely
The fix pattern:
function NewYearCountdown() {
const [time, setTime] = useState(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(interval);
}, []);
return <p>Current time: {time ?? 'Loading...'}</p>;
}
Understanding the Hydration Performance Cost
Hydration is expensive. Let's understand why:
HYDRATION WORK:
1. Download JS bundle (network time)
2. Parse JavaScript (CPU time)
3. Execute all component code (CPU time)
4. Build virtual DOM tree (memory + CPU)
5. Walk real DOM tree (CPU time)
6. Compare virtual vs real (CPU time)
7. Attach all event listeners (memory)
8. Initialize all state (memory)
Measuring hydration cost:
const startMark = performance.now();
hydrateRoot(container, <App />);
const endMark = performance.now();
console.log(`Hydration took ${endMark - startMark}ms`);
<Profiler id="App" onRender={(id, phase, duration) => {
if (phase === 'mount') {
// This includes hydration time
console.log(`${id} hydration: ${duration}ms`);
}
}}>
<App />
</Profiler>
Islands Architecture: The Mental Model
Traditional hydration thinks in pages. Islands thinks in components:
TRADITIONAL (Page-Level Hydration):
┌──────────────────────────────────────────────┐
│ ┌──────────────────────────────────────┐ │
│ │ Page Component │ │
│ │ ┌──────┐ ┌──────┐ ┌──────────────┐ │ │
│ │ │Header│ │ Nav │ │ Content │ │ │
│ │ └──────┘ └──────┘ └──────────────┘ │ │
│ │ ┌──────┐ ┌──────────────────────┐ │ │
│ │ │Button│ │ Comments │ │ │
│ │ └──────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
│ │
│ ENTIRE TREE must be hydrated │
│ One root, all components connected │
└──────────────────────────────────────────────┘
ISLANDS (Component-Level Hydration):
┌──────────────────────────────────────────────┐
│ │
│ Static HTML (no JS required) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Island: Nav │ │ Island: Search │ │
│ │ (own JS, own │ │ (own JS, own │ │
│ │ hydration) │ │ hydration) │ │
│ └──────────────┘ └──────────────────┘ │
│ │
│ More static HTML... │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Island: Comments Widget │ │
│ │ (loads only when visible) │ │
│ └──────────────────────────────────────┘ │
│ │
│ Each island is INDEPENDENT │
│ Hydrate separately, fail separately │
└──────────────────────────────────────────────┘
Islands technical implementation:
<html>
<body>
<header>Static header content</header>
<astro-island
component-url="/components/Counter.js"
client="visible"
>
<button>Count: 0</button>
</astro-island>
<main>Static main content</main>
<astro-island
component-url="/components/Comments.js"
client="idle"
props='{"postId":123}'
>
<div>Loading comments...</div>
</astro-island>
<footer>Static footer</footer>
</body>
</html>
Resumability: How Qwik Eliminates Hydration
Qwik takes a radically different approach. Instead of re-running code to figure out what event handlers should do, it serializes everything:
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
return <button onClick={increment}>Count: {count}</button>;
}
function Counter() {
const count = useSignal(0);
return <button onClick$={() => count.value++}>Count: {count.value}</button>;
}
<button
on:click="counter_onclick_abc123.js#s0"
q:obj="0"
>Count: 0</button>
<script type="qwik/json">{"signals":{"0":0}}</script>
Why this matters:
HYDRATION TIMELINE:
[Page Loads] → [Download ALL JS] → [Execute ALL code] → [Interactive]
[====== Blocking, nothing works ======]
RESUMABILITY TIMELINE:
[Page Loads] → [Interactive immediately]
[Download handler only on first interaction]
// 100KB app with hydration: 500ms until interactive
// 100KB app with resumability: 0ms until interactive (JS loads on demand)
Partial Hydration: The Compiler's Role
Frameworks like Astro use compilers to determine what needs JavaScript:
---
import Header from './Header.astro';
import Counter from './Counter.jsx';
---
<Header />
<Counter client:load />
The Event Listener Memory Model
Understanding how event listeners work clarifies hydration cost:
button.addEventListener('click', () => console.log('clicked'));
<button onClick={() => console.log('clicked')}>
// React stores in its own system:
// - Element reference
// - Event type
// - Handler function
// - Handler closure scope (variables it captures)
// - Fiber node reference
// - Event priority
// Memory: ~500 bytes per listener + closure scope
// A page with 100 interactive elements:
// Vanilla: ~10KB in event system
// React: ~50KB+ in React's event system + component tree
This is why hydration is expensive - it's rebuilding all these data structures.
Streaming Hydration: Out of Order
React 18's streaming SSR allows components to hydrate out of order:
<Layout>
<Header /> {}
<Suspense fallback={<Spinner />}>
<SlowComments /> {}
</Suspense>
<Suspense fallback={<Spinner />}>
<SlowSidebar /> {/* Can hydrate before or after Comments */}
</Suspense>
<Footer /> {}
</Layout>
The streaming mechanism:
1. Server streams shell immediately:
<html><body><div id="header">...</div><div id="comments">Loading...</div>
2. As data arrives, server streams script tags:
<script>
$RC('comments', '<div class="comments">Real comments...</div>');
</script>
3. $RC (React's internal function) swaps content and triggers hydration
for just that subtree
4. User can interact with Header while Comments still loading
Real-World Hydration Optimization Strategies
1. Code Splitting by Route:
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
2. Deferred Hydration:
import { startTransition } from 'react';
hydrateRoot(headerContainer, <Header />);
startTransition(() => {
hydrateRoot(commentsContainer, <Comments />);
});
3. Intersection Observer Pattern:
function useHydrateOnVisible(ref) {
const [shouldHydrate, setShouldHydrate] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldHydrate(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return shouldHydrate;
}
4. Static Extraction:
function ProductDescription({ text }) {
return <p className="description">{text}</p>;
}
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
const handleClick = () => { };
return <button onClick={handleClick}>Add to Cart</button>;
}
For Framework Authors: Building Hydration Systems
Implementation Note: The patterns and code examples below represent one proven approach to building hydration systems. Hydration is one of the most complex areas in framework development with many valid strategies—from React's reconciliation-based approach to Qwik's resumability to Astro's islands. The direction shown here provides foundational concepts; adapt these patterns based on your framework's rendering model, state management, and performance priorities.
Implementing Basic Hydration
function hydrateRoot(container, element) {
const existingNodes = Array.from(container.childNodes);
hydrateNode(element, existingNodes[0], container);
}
function hydrateNode(vnode, domNode, parent) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
if (domNode?.nodeType === Node.TEXT_NODE) {
if (domNode.textContent !== String(vnode)) {
console.warn('Hydration mismatch:', domNode.textContent, '!=', vnode);
domNode.textContent = vnode;
}
}
return domNode;
}
if (!vnode) return null;
const { type, props } = vnode;
if (typeof type === 'function') {
const result = type(props);
return hydrateNode(result, domNode, parent);
}
if (domNode?.nodeName?.toLowerCase() !== type) {
console.error('Hydration mismatch: expected', type, 'got', domNode?.nodeName);
const newNode = render(vnode);
parent.replaceChild(newNode, domNode);
return newNode;
}
attachEventListeners(domNode, props);
const children = Array.isArray(props.children)
? props.children
: props.children ? [props.children] : [];
const domChildren = Array.from(domNode.childNodes);
children.forEach((child, i) => {
hydrateNode(child, domChildren[i], domNode);
});
return domNode;
}
function attachEventListeners(element, props) {
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function') {
const eventName = key.slice(2).toLowerCase();
element.addEventListener(eventName, value);
}
});
}
Building a Hydration Scheduler
class HydrationScheduler {
constructor() {
this.queue = [];
this.isHydrating = false;
this.idleDeadline = 50;
}
schedule(component, priority = 'normal') {
this.queue.push({ component, priority });
this.sortQueue();
this.processQueue();
}
sortQueue() {
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
this.queue.sort((a, b) =>
priorityOrder[a.priority] - priorityOrder[b.priority]
);
}
processQueue() {
if (this.isHydrating || this.queue.length === 0) return;
this.isHydrating = true;
requestIdleCallback((deadline) => {
while (
this.queue.length > 0 &&
(deadline.timeRemaining() > 0 || deadline.didTimeout)
) {
const { component } = this.queue.shift();
this.hydrateComponent(component);
}
this.isHydrating = false;
if (this.queue.length > 0) {
this.processQueue();
}
}, { timeout: 1000 });
}
hydrateComponent(component) {
const element = document.querySelector(
`[data-hydrate="${component.id}"]`
);
if (element) {
hydrateRoot(element, component.vnode);
}
}
}
class VisibilityHydration {
constructor(scheduler) {
this.scheduler = scheduler;
this.observer = new IntersectionObserver(
(entries) => this.onIntersect(entries),
{ rootMargin: '200px' }
);
}
observe(element, component) {
element.__component = component;
this.observer.observe(element);
}
onIntersect(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.observer.unobserve(entry.target);
this.scheduler.schedule(entry.target.__component, 'high');
}
});
}
}
Implementing Resumability (Qwik-style)
function serializeComponent(component, props) {
const handlers = {};
const state = {};
Object.entries(props).forEach(([key, value]) => {
if (key.startsWith('on') && typeof value === 'function') {
const handlerId = generateHandlerId(value);
handlers[key] = handlerId;
registerHandler(handlerId, {
module: component.__module,
export: value.name,
});
} else {
state[key] = value;
}
});
return {
html: renderToString(component, props),
stateScript: `<script type="qwik/json">${JSON.stringify({
state,
handlers,
})}</script>`,
};
}
function resume() {
const stateScript = document.querySelector('script[type="qwik/json"]');
const { state, handlers } = JSON.parse(stateScript.textContent);
document.addEventListener('click', async (event) => {
const target = event.target.closest('[on\\:click]');
if (!target) return;
const handlerId = target.getAttribute('on:click');
const handlerInfo = getHandler(handlerId);
const module = await import(handlerInfo.module);
const handler = module[handlerInfo.export];
handler(event, state);
});
}
function renderResumable(component, props) {
const { html, stateScript } = serializeComponent(component, props);
return `
${html}
${stateScript}
<script src="/qwik-loader.js" async></script>
`;
}
Building Islands Architecture
function analyzeComponent(component, ast) {
return {
hasState: astContains(ast, 'useState', 'useSignal', 'createSignal'),
hasEffects: astContains(ast, 'useEffect', 'onMount'),
hasHandlers: astContains(ast, /^on[A-Z]/),
imports: extractImports(ast),
};
}
function markIslands(html, components) {
return html.replace(
/<([A-Z]\w+)([^>]*)client:(\w+)([^>]*)>/g,
(match, name, before, strategy, after) => {
const component = components[name];
const props = extractProps(before + after);
return `<astro-island
component-url="${component.url}"
component-export="${component.export}"
renderer-url="${component.renderer}"
props="${encodeProps(props)}"
client="${strategy}"
${before}${after}>`;
}
);
}
class AstroIsland extends HTMLElement {
async connectedCallback() {
const strategy = this.getAttribute('client');
switch (strategy) {
case 'load':
await this.hydrate();
break;
case 'idle':
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.hydrate());
} else {
setTimeout(() => this.hydrate(), 200);
}
break;
case 'visible':
const observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting) {
observer.disconnect();
await this.hydrate();
}
});
observer.observe(this);
break;
case 'media':
const query = this.getAttribute('client-media');
const mq = window.matchMedia(query);
if (mq.matches) {
await this.hydrate();
} else {
mq.addEventListener('change', () => this.hydrate(), { once: true });
}
break;
}
}
async hydrate() {
const [Component, { default: render }] = await Promise.all([
import(this.getAttribute('component-url')),
import(this.getAttribute('renderer-url')),
]);
const props = JSON.parse(
decodeURIComponent(this.getAttribute('props'))
);
const exportName = this.getAttribute('component-export');
await render(
this,
Component[exportName],
props,
this.innerHTML
);
}
}
customElements.define('astro-island', AstroIsland);
State Serialization for Hydration
class StateSerializer {
constructor() {
this.stateMap = new Map();
this.counter = 0;
}
captureState(componentId, state) {
this.stateMap.set(componentId, this.serialize(state));
}
serialize(value) {
if (value === null || value === undefined) return value;
if (typeof value === 'function') return undefined;
if (value instanceof Date) return { __type: 'Date', value: value.toISOString() };
if (value instanceof Map) return { __type: 'Map', value: Array.from(value) };
if (value instanceof Set) return { __type: 'Set', value: Array.from(value) };
if (ArrayBuffer.isView(value)) {
return { __type: 'TypedArray', ctor: value.constructor.name, value: Array.from(value) };
}
if (Array.isArray(value)) return value.map(v => this.serialize(v));
if (typeof value === 'object') {
const result = {};
for (const [k, v] of Object.entries(value)) {
result[k] = this.serialize(v);
}
return result;
}
return value;
}
emit() {
const data = Object.fromEntries(this.stateMap);
return `<script id="__HYDRATION_STATE__" type="application/json">${
JSON.stringify(data).replace(/</g, '\\u003c')
}</script>`;
}
}
function restoreState() {
const script = document.getElementById('__HYDRATION_STATE__');
if (!script) return new Map();
const data = JSON.parse(script.textContent);
const stateMap = new Map();
for (const [id, serialized] of Object.entries(data)) {
stateMap.set(id, deserialize(serialized));
}
return stateMap;
}
function deserialize(value) {
if (value === null || value === undefined) return value;
if (value?.__type === 'Date') return new Date(value.value);
if (value?.__type === 'Map') return new Map(value.value);
if (value?.__type === 'Set') return new Set(value.value);
if (value?.__type === 'TypedArray') {
const Ctor = globalThis[value.ctor];
return new Ctor(value.value);
}
if (Array.isArray(value)) return value.map(deserialize);
if (typeof value === 'object') {
const result = {};
for (const [k, v] of Object.entries(value)) {
result[k] = deserialize(v);
}
return result;
}
return value;
}
Related Skills