---
title: "Fix Hydration Issues for Better INP"
description: "Framework hydration blocks interactivity. Delay and partial hydration strategies to make SSR sites responsive faster."
canonical_url: "https://unlighthouse.dev/learn-lighthouse/inp/hydration-issues"
last_updated: "2025-01-18"
---

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 [Google research](https://web.dev/articles/why-speed-matters) found that bounce probability increases 90% as load time goes from 1s to 5s. Hydration is often why time-to-interactive exceeds first contentful paint by 2-4 seconds.

**Real-world impact:** A [React](https://react.dev) 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:

```js
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.

```js
// 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](https://astro.build) pioneered this pattern:

```astro
---
// 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:

```js
// 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, 2000, container, component)
  }
}
```

### 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.

```jsx
// 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 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.

```jsx
// Before: deeply nested (slow hydration)
export function NestedApp() {
  return (
    <App>
      <Layout>
        <PageWrapper>
          <ContentContainer>
            <Section>
              <ProductCard>
                <ProductInfo>
                  <ProductPrice />
                </ProductInfo>
              </ProductCard>
            </Section>
          </ContentContainer>
        </PageWrapper>
      </Layout>
    </App>
  )
}

// After: flattened (faster hydration)
export function FlatApp() {
  return (
    <App>
      <>
        <Header />
        <ProductCard product={product} />
        <Footer />
      </>
    </App>
  )
}
```

Wrapper components that add no interactivity should become Server Components or plain HTML.

## Framework-specific solutions

<callout icon="i-logos-nextjs-icon">

**Next.js (App Router)**

Server Components are the default. Push `'use client'` boundaries as low as possible:

```jsx
// 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.

</callout>

<callout icon="i-logos-nuxt-icon">

**Nuxt 3**

Use `nuxt-delay-hydration` module for automatic delay:

```ts
// 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:

```html
<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.

</callout>

<callout icon="i-simple-icons-astro">

**Astro**

Islands are built-in. Choose hydration timing with directives:

```astro
<!-- 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)" />
```

</callout>

<callout icon="i-simple-icons-qwik">

**Qwik**

[Qwik](https://qwik.dev) uses "resumability" instead of hydration. The server serializes component state and event handlers. On the client, the browser loads handlers on demand when users interact - no upfront hydration.

```jsx
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.

</callout>

## 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

<audit-impact current-value="800" metric="tbt" target-value="200">



</audit-impact>

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](/learn-lighthouse/inp/long-running-javascript) - Hydration IS long-running JavaScript
- [Third-Party Scripts](/learn-lighthouse/inp/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.
