Fix Hydration Issues for Better INP

Framework hydration blocks interactivity. Delay and partial hydration strategies to make SSR sites responsive faster.
Harlan WiltonHarlan Wilton7 min read Published

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:

  1. Downloads the component JavaScript
  2. Parses and executes that JavaScript
  3. Walks the DOM tree matching components
  4. Attaches event handlers to elements
  5. 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.

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:

  1. Record a Performance trace starting before page load
  2. Click a button immediately when the page appears rendered
  3. Look for the click event—a long gap before your handler runs indicates hydration blocking
  4. 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

Next.js (App Router)Server Components are the default. Push '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>
  )
}
For Pages Router, use dynamic imports with ssr: false for non-critical components.
Nuxt 3Use 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
  }
})
For component-level control:
<template>
  <!-- Immediate hydration -->
  <Navigation />

  <!-- Delay hydration until idle -->
  <LazyComments v-if="showComments" />

  <!-- No hydration (static) -->
  <ClientOnly>
    <InteractiveWidget />
    <template #fallback>
      <WidgetPlaceholder />
    </template>
  </ClientOnly>
</template>
Nuxt Islands (experimental) provide Astro-like partial hydration.
AstroIslands are built-in. Choose hydration timing with directives:
<!-- 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)" />
QwikQwik uses "resumability" instead of hydration. The server serializes component state and event handlers. On the client, handlers are loaded on demand when users interact—no upfront hydration.
export const Counter = component$(() => {
  const count = useSignal(0)

  return (
    // Handler loads only when clicked
    <button onClick$={() => count.value++}>
      {count.value}
    </button>
  )
})
Qwik apps have near-zero JavaScript execution at startup, making early interactions instant.

Verify the Fix

After changes:

  1. Test early interactions—click buttons within 1 second of page appearing
  2. Compare time-to-interactive vs first contentful paint—gap should shrink
  3. Monitor INP for interactions in first 10 seconds of page load
  4. Profile hydration time in DevTools—search for hydration-related functions
TBTTotal Blocking Time
30% weight
Good ≤200msPoor >600ms

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.

Hydration problems often combine with:

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.