Hydration can block interactions for 500ms to 3 seconds on complex pages. Users see a fully rendered page, try to interact, and nothing happens—the JavaScript framework is busy "waking up" server-rendered HTML.
Server-side rendering delivers HTML fast, but that HTML is static. Buttons don't work, forms don't submit, and dropdowns don't open until hydration completes. Hydration is the process where a JavaScript framework:
During steps 2-5, the main thread is blocked. Any user interaction during this time gets high input delay—the click event queues until hydration finishes.
The Akamai mPulse study found that 53% of mobile users abandon pages taking longer than 3 seconds to become interactive. Hydration is often why time-to-interactive exceeds first contentful paint by 2-4 seconds.
Real-world impact: A React e-commerce site with 200 components takes 800ms to hydrate on a mobile device. Every early interaction during that 800ms window has 800ms+ INP.
Hydration issues manifest as early interactions with high input delay. Use web-vitals to detect this pattern:
import { onINP } from 'web-vitals/attribution'
onINP((metric) => {
const { inputDelay, interactionTime } = metric.attribution
// Early interaction with high input delay suggests hydration blocking
if (inputDelay > 200 && interactionTime < 10000) {
console.warn('Possible hydration blocking:', {
inputDelay,
interactionTime,
target: metric.attribution.interactionTarget
})
}
})
In Chrome DevTools:
hydrate, hydrateRoot, $mount, $hydrateManual testing: load your page on a throttled connection, click buttons as soon as they appear. If clicks are ignored or severely delayed, hydration is the bottleneck.
Hydrate critical interactive elements first, defer the rest. Users interact with navigation and CTAs immediately; comments and footers can wait.
// Hydrate immediately
hydrateRoot(document.getElementById('nav'), <Navigation />)
hydrateRoot(document.getElementById('hero-cta'), <HeroCTA />)
// Hydrate when idle
requestIdleCallback(() => {
hydrateRoot(document.getElementById('comments'), <Comments />)
})
// Hydrate when visible
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
hydrateRoot(entry.target, <Footer />)
observer.disconnect()
}
})
})
observer.observe(document.getElementById('footer'))
Only hydrate interactive parts of the page. Static content stays as plain HTML—no JavaScript attached, no hydration cost.
Astro pioneered this pattern:
---
// Static by default - no JS
import Header from './Header.astro'
import ProductInfo from './ProductInfo.astro'
// Interactive island - hydrates independently
import AddToCart from './AddToCart.jsx'
---
<Header /> <!-- Static HTML, zero JS -->
<ProductInfo product={product} /> <!-- Static HTML, zero JS -->
<AddToCart client:visible product={product} /> <!-- Hydrates when scrolled into view -->
The result: a page with 50 components might only hydrate 3-4 interactive islands. Total hydration time drops from 800ms to 80ms.
Components not visible on initial load don't need immediate hydration:
// Lazy hydration utility
function hydrateOnVisible(container, component) {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
hydrateRoot(container, component)
observer.disconnect()
}
}, { rootMargin: '200px' }) // Start hydrating slightly before visible
observer.observe(container)
}
else {
// Fallback: hydrate after delay
setTimeout(() => hydrateRoot(container, component), 2000)
}
}
React Server Components don't hydrate at all—they render on the server and send HTML. Only 'use client' components require hydration.
// app/page.js - Server Component (no hydration)
export default function ProductPage({ product }) {
return (
<div>
{/* These never hydrate - just HTML */}
<ProductTitle title={product.title} />
<ProductDescription description={product.description} />
<ProductSpecs specs={product.specs} />
{/* Only this hydrates */}
<AddToCartButton productId={product.id} />
</div>
)
}
// components/AddToCartButton.js
'use client' // This component hydrates
export function AddToCartButton({ productId }) {
const [adding, setAdding] = useState(false)
// Interactive logic...
}
Server Components can dramatically reduce hydration—a page might go from hydrating 100 components to hydrating 5.
Deep component trees take longer to hydrate. Each level adds reconciliation overhead.
// Before: deeply nested (slow hydration)
<App>
<Layout>
<PageWrapper>
<ContentContainer>
<Section>
<ProductCard>
<ProductInfo>
<ProductPrice />
</ProductInfo>
</ProductCard>
</Section>
</ContentContainer>
</PageWrapper>
</Layout>
</App>
// After: flattened (faster hydration)
<App>
<Header />
<ProductCard product={product} />
<Footer />
</App>
Wrapper components that add no interactivity should become Server Components or plain HTML.
'use client' boundaries as low as possible:// app/product/[id]/page.js
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return (
<div>
{/* Server rendered, no hydration */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
{/* Client boundary - only this tree hydrates */}
<ProductActions product={product} />
</div>
)
}
dynamic imports with ssr: false for non-critical components.nuxt-delay-hydration module for automatic delay:// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-delay-hydration'],
delayHydration: {
mode: 'init', // or 'mount' for more aggressive
debug: process.dev
}
})
<template>
<!-- Immediate hydration -->
<Navigation />
<!-- Delay hydration until idle -->
<LazyComments v-if="showComments" />
<!-- No hydration (static) -->
<ClientOnly>
<InteractiveWidget />
<template #fallback>
<WidgetPlaceholder />
</template>
</ClientOnly>
</template>
<!-- Never hydrates - static HTML -->
<StaticComponent />
<!-- Hydrates immediately -->
<Interactive client:load />
<!-- Hydrates when visible -->
<BelowFold client:visible />
<!-- Hydrates when idle -->
<LowPriority client:idle />
<!-- Hydrates on media query -->
<MobileOnly client:media="(max-width: 768px)" />
export const Counter = component$(() => {
const count = useSignal(0)
return (
// Handler loads only when clicked
<button onClick$={() => count.value++}>
{count.value}
</button>
)
})
After changes:
Expect 50-80% reduction in early-interaction INP with proper hydration strategies.
Hydrating everything — Even "lazy" loaded components hydrate when their chunk loads. Use true partial hydration (islands) or Server Components.
Deep component trees — Wrapper components that exist only for styling or layout still need hydration. Flatten or make them server-only.
Hydrating invisible components — Modals, drawers, and tabs that aren't visible on load shouldn't hydrate until needed.
Not measuring the right thing — Lab tools often test after hydration completes. Use RUM data to catch real-user early interactions.
Hydration problems often combine with:
Hydration impact varies by page complexity. A simple about page hydrates fast; a product page with reviews, recommendations, and interactive filters takes much longer. Unlighthouse scans your entire site and surfaces TBT during page load across all pages.