DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Progressive Hydration in React — Client Islands & Triggers

Why progressive hydration React matters now

Hydration is where server-rendered HTML becomes interactive on the client. When done all at once, it can spike main-thread work and cause slow real-world responsiveness measured by INP (Interaction to Next Paint). Progressive hydration—also called partial or selective hydration—lets you hydrate only the pieces users interact with, reducing wasted work and improving perceived performance.

React 19 adds the use() API and continued improvements to Suspense, Server Components, and hydration diagnostics. That makes progressive hydration easier and safer to implement without hacks. Teams from Wix to Vercel have reported meaningful INP and payload wins by deferring non-essential hydration.

The performance payoff: INP and real users

INP replaced FID as the primary interactivity metric in Core Web Vitals. It measures how long users wait for the next paint after they interact. Hydration often dominates main-thread CPU on page load; delaying or batching it directly reduces the work blocking user interactions.

Key idea: server-render everything users need to read, and only hydrate the interactive islands when they matter. That reduces JavaScript execution at startup and keeps the main thread free for real interactions.

The 3-step checklist for safe client islands

Below is a practical checklist you can run today to build client islands that hydrate on demand and avoid common hydration pitfalls.

1) Pick real islands

  • Audit your UI for true interaction hot spots: forms, comment boxes, complex widgets (maps, editors), search, and any UI that runs expensive logic on first use.
  • If a region is static or purely presentational, keep it as a Server Component — no client bundle, no hydration cost.
  • Prioritize by user impact: anything that matters to the user’s primary task or that currently contributes to poor INP should hydrate earlier.

Tip: map interactions with an analytics heatmap or lightweight logging on slow devices to find which components users actually touch.

2) Boundaries must be safe

Hydration mismatches are a common failure mode: server HTML and client-rendered markup must match exactly for a smooth hydration. React 19 improves mismatch diagnostics and offers helpers (like useId) — but you still need deterministic HTML.

Do this:

  • Wrap islands in Suspense boundaries. Suspense gives React a controlled way to defer work and show a fallback without changing server/ client structure.
  • Avoid runtime randomness in markup: don’t call Math.random() or Date.now() in render, and avoid branching that depends on typeof window !== 'undefined' in markup paths.
  • Use React’s useId() for stable IDs across server and client.
  • Keep the HTML structure identical on server and client; only the resolution timing (whether the island is hydrated now or later) should differ.

React 19 also logs a single hydration-diff message when mismatches occur, which makes it easier to find the problematic component.

3) Trigger intentionally

Don’t hydrate by default. Instead, hydrate on visibility or on first interaction. These triggers are predictable and avoid unnecessary main-thread costs.

Common triggers:

  • Visibility: use IntersectionObserver to hydrate when an island scrolls into view (good for below-the-fold content).
  • Interaction: attach tiny event listeners on cheap trigger elements (click, focus, hover) that call hydrate when the user signals intent.
  • Idle and priority: hydrate low-priority islands on requestIdleCallback or when the browser is idle.

Example: hydrate on visibility

// client-only component (use client)
import { useEffect, useRef } from 'react';

export default function HydrateOnVisible({ hydrate }) {
  const ref = useRef();

  useEffect(() => {
    if (!ref.current) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        io.disconnect();
        hydrate(); // trigger loading/hydration for the island
      }
    }, { rootMargin: '200px' });

    io.observe(ref.current);
    return () => io.disconnect();
  }, [hydrate]);

  return <div ref={ref} aria-hidden="true" />;
}
Enter fullscreen mode Exit fullscreen mode

This attaches a lightweight observer and calls hydrate() only when the node approaches the viewport.

Two small code patterns: Suspense + use() and interaction trigger

React 19's use() simplifies the old trick where apps "threw" promises to pause hydration. With use(), you can await arbitrary signals from render inside a Suspense boundary.

Example: suspend until an external promise resolves (simplified)

import { Suspense } from 'react';
import { use } from 'react';

function AwaitIntent({ intentPromise, children }) {
  // this pauses rendering inside the nearest Suspense boundary until intentPromise resolves
  use(intentPromise);
  return children;
}

// Usage
// <Suspense fallback={<Placeholder/>}>
//   <AwaitIntent intentPromise={onVisiblePromise()}>
//     <HeavyWidget />
//   </AwaitIntent>
// </Suspense>
Enter fullscreen mode Exit fullscreen mode

Combine this pattern with an IntersectionObserver or an explicit click promise to create deterministic, Suspense-managed hydration without manual "throwing." Remember: use() must run inside a Suspense boundary and promises passed to use() should be cached (not created during render) to avoid warnings.

A concrete story

I migrated a heavy comment widget to a server-rendered placeholder + Suspense-wrapped client island that only hydrated on click. The placeholder showed immediately; the main thread stayed clean during initial load, and the comment UI hydrated only when users actually clicked to reply. The result: faster page load, much lower hydration CPU at startup, and noticeably snappier interactions on slow devices.

Wix reported similar wins when rolling out selective hydration at scale, improving INP and reducing bundle work by deferring hydration for non-essential widgets.

Small checklist you can run today

  1. Audit: build an interaction heatmap (analytics + manual sampling on slow devices).
  2. Wrap candidate islands in Suspense and confirm server/client markup is identical.
  3. Add visibility (IntersectionObserver) or interaction triggers and test on throttled CPU/devices.
  4. Use useId() for stable IDs and avoid runtime randomness in render.
  5. Measure INP in production (real-user monitoring) and iterate.

Final tips and trade-offs

  • Prefetch critical client islands above the fold so they hydrate immediately once the app boots. Use prefetch/preload strategically.
  • Group observers to avoid creating hundreds of IntersectionObserver targets; prefer container-level observers when appropriate.
  • Progressive hydration is about trade-offs: slightly slower first interaction for a below-the-fold widget may be a good price to keep the main thread free for the primary path.

Progressive hydration with React 19 primitives (Suspense, use(), Server Components) gives you a robust, maintainable way to reduce hydration cost and improve INP. What’s the one interactive component in your product you’d hydrate last — and why?

Top comments (0)