Back to Blog
PerformanceJune 29, 20264 min read

Interaction to Next Paint: Improving INP with Lazy Hydration

Improve Interaction to Next Paint (INP) by deferring component hydration. Learn how to use IntersectionObserver to keep your main thread responsive.

performancereacthydrationweb-vitalsINPjavascriptWeb VitalsFrontend

We’ve all been there: the lighthouse score looks decent, but the site feels sluggish the moment a user tries to click a button. Last month, while auditing a dashboard with massive data tables, I realized our heavy charting libraries were locking the main thread for over 400ms during the initial hydration phase. That’s a death sentence for your Interaction to Next Paint (INP) score.

When the browser is busy parsing and executing JavaScript for components that aren't even on the user's screen yet, it can't respond to clicks or taps. We need a way to tell the browser: "Don't touch this code until the user is actually looking at it."

Why Hydration Blocks the Main Thread

Hydration is essentially a tax you pay for the convenience of Server-Side Rendering (SSR). React (or your framework of choice) has to walk the DOM tree, attach event listeners, and reconcile the static HTML with the virtual DOM. If your page is large, this process is a long, uninterrupted task.

If you are interested in the broader ecosystem of keeping your main thread free, I’ve previously written about INP optimization: Architecting non-blocking DOM updates and how to manage these heavy tasks. However, even with batching, the sheer volume of JS execution during hydration remains a major bottleneck.

Implementing Lazy Hydration with IntersectionObserver

The goal is to delay the mounting of heavy components until they enter the viewport. We aren't just lazy-loading the code chunk; we are lazy-loading the execution of the component itself.

We first tried using React.lazy on its own, but that only handles the network request for the bundle. It doesn't prevent the hydration process from triggering if the component is already in the DOM. Instead, we need a wrapper that uses IntersectionObserver to trigger the render.

Here is the pattern I settled on:

JAVASCRIPT
import React, { useState, useEffect, useRef } from CE9178">'react';

const LazyHydrate = ({ children }) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsIntersecting(true);
        observer.disconnect();
      }
    });

    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return <div ref={ref}>{isIntersecting ? children : null}</div>;
};

This approach is simple, but effective. By wrapping components that sit below the fold, you effectively move their hydration cost out of the critical path.

Trade-offs and "Gotchas"

It isn't a silver bullet. If you lazily hydrate a component, you lose the ability to capture user interactions that happen before the component mounts.

StrategyPerformance GainInteraction RiskImplementation Effort
Eager HydrationNoneLowZero
React.lazyModerateLowLow
Intersection HydrationHighHighModerate

When we first tested this, we realized that if a user scrolled down extremely fast and clicked a "Buy" button inside a lazy-hydrated component, the click would be ignored because the component wasn't interactive yet. We had to add a small "buffer" margin to the IntersectionObserver root margin to start the hydration process about 200px before the component hits the screen.

Deeper Performance Optimization

Managing hydration is just one piece of the puzzle. If you're building complex interfaces, you should also look into improving INP via selective hydration and React Suspense to further break down those long tasks.

Also, don't forget that your asset loading strategy matters. If you're fetching massive JS bundles while trying to hydrate, you're competing for resources. Mastering optimizing asset loading is just as important as the hydration logic itself.

FAQ

Q: Does this hurt SEO? A: No. Since the HTML is already rendered on the server (SSR), Google sees the content immediately. The hydration is purely a client-side interactivity concern.

Q: Can I use this for everything? A: Definitely not. Only use this for heavy, non-critical components below the fold. Never lazy-hydrate your header, navigation, or primary call-to-action buttons.

Q: Is there a native way to do this? A: Not exactly. While frameworks are moving toward "resumability" (like Qwik), if you are stuck in the React ecosystem, this pattern is the most reliable way to gain control over your hydration timing.

I’m still experimenting with how to handle state synchronization between lazily hydrated components and the rest of the app. It gets messy when you have a global state store that expects everything to be mounted. For now, I’m keeping these lazy components as self-contained as possible, but I’m curious to see how things evolve as the React team continues to iterate on server components.

Similar Posts