Fix Hydration Issues for Better INP
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.
What's the Problem?
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:
- Downloads the component JavaScript
- Parses and executes that JavaScript
- Walks the DOM tree matching components
- Attaches event handlers to elements
- Initializes reactive state
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.
The "Uncanny Valley" of Interactivity
Hydration creates a deceptive state: the "Uncanny Valley."
- 0s - 1.5s: Page is white (Loading). Users wait.
- 1.5s - 3.5s: Page looks complete (The Valley). Users try to click.
- 3.5s: Page becomes interactive.
The danger is that users rely on visual cues. If they see a button, they click it. If the main thread is locked in a hydration loop, that click goes into a void, resulting in a "Broken" feeling and a poor INP score.
Double Hydration: A common bug where server-rendered HTML doesn't match the client-side initial render (e.g., mismatched timestamps or IDs). React is forced to discard the HTML and re-render the entire tree from scratch. This doubles the work and kills INP.
How to Identify the Issue
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:
- Record a Performance trace starting before page load
- Click a button immediately when the page appears rendered
- Look for the click event—a long gap before your handler runs indicates hydration blocking
- Find hydration tasks by searching for framework-specific functions:
hydrate,hydrateRoot,$mount,$hydrate
Manual 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.
The Fix
Progressive Hydration
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'))
Islands Architecture
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.
Defer Below-Fold Hydration
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)
}
}
Server Components (React)
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.
Reduce Component Tree Depth
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.
Framework-Specific Solutions
'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>
)
})
Verify the Fix
After changes:
- Test early interactions—click buttons within 1 second of page appearing
- Compare time-to-interactive vs first contentful paint—gap should shrink
- Monitor INP for interactions in first 10 seconds of page load
- Profile hydration time in DevTools—search for hydration-related functions
Expect 50-80% reduction in early-interaction INP with proper hydration strategies.
Common Mistakes
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.
Related Issues
Hydration problems often combine with:
- Long-Running JavaScript — Hydration IS long-running JavaScript
- Third-Party Scripts — Scripts competing with hydration for main thread
Test Your Entire Site
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.