Skip to main content

Advanced: Selective Hydration and Lazy Component Boundaries

Selective hydration is an advanced optimization technique that defers hydrating (attaching interactivity to) non-critical components until they're actually needed. Combined with Suspense boundaries, it allows you to send HTML to the browser faster, make the page interactive sooner, and hydrate heavy components (modals, sidebars) only when the user interacts with them.

This article covers how selective hydration works, when to apply it, and practical patterns for maximizing Interaction to Next Paint (INP) and reducing Time to Interactive (TTI).

Understanding Hydration and Its Cost

Traditional server-side rendering (SSR) has two phases:

  1. Server rendering: Server generates HTML from React components.
  2. Hydration: Browser downloads JavaScript, React initializes, and attaches event listeners to HTML (making it interactive).

Both phases must complete before the page is truly interactive. If hydration takes 3 seconds and the user tries to click a button at 2 seconds, nothing happens—the page is still hydrating.

Selective hydration changes this: Hydrate critical components first, defer non-critical ones. The user can interact with critical parts (buttons, forms) while heavy components (sidebars, modals) are still hydrating.

How Suspense Enables Selective Hydration

In Next.js 14+ with Server Components, Suspense boundaries are the mechanism for selective hydration. Components inside boundaries hydrate in order of resolution:

// app/dashboard/page.js (Server Component)
import { Suspense } from 'react';

async function Header() {
const user = await fetchUser();
return (
<header>
<h1>{user.name}</h1>
<button>Logout</button> {/* Interactive, priority 1 */}
</header>
);
}

async function Sidebar() {
const nav = await fetchNavigation();
return (
<nav>
{/* Navigation links, lots of event listeners, priority 2 */}
{nav.items.map(item => (
<a key={item.id} href={item.url}>{item.label}</a>
))}
</nav>
);
}

async function MainContent() {
const content = await fetchContent();
return (
<main>
{/* Main content, priority 1 (critical for user engagement) */}
{content.body}
</main>
);
}

export default function Dashboard() {
return (
<div>
{/* Header and content hydrate first */}
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>

<div style={{ display: 'flex' }}>
{/* Sidebar hydrates second (less critical) */}
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>

{/* Main content hydrates with header (same priority) */}
<Suspense fallback={<div>Loading content...</div>}>
<MainContent />
</Suspense>
</div>
</div>
);
}

Next.js automatically hydrates in order: Header and MainContent hydrate first (they appear above the fold), Sidebar hydrates after. If the JavaScript bundle is large, MainContent becomes interactive before the sidebar JavaScript is even downloaded.

Lazy Boundaries: Deferring Components Until Interaction

For truly non-essential components (modals, tooltips, drawers), use lazy boundaries that don't hydrate until the user interacts with them:

'use client';

import { Suspense, useState } from 'react';
import dynamic from 'next/dynamic';

// Lazy-load the modal (not hydrated until opened)
const LazyModal = dynamic(() => import('./Modal'), {
loading: () => <div>Loading modal...</div>,
ssr: false, // Don't render on server; skip hydration until needed
});

export function Dashboard() {
const [showModal, setShowModal] = useState(false);

return (
<div>
<button onClick={() => setShowModal(true)}>Open Settings</button>

{/* Modal only hydrates when showModal is true */}
{showModal && (
<Suspense fallback={<div>Loading modal...</div>}>
<LazyModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}

With ssr: false, Next.js doesn't render the modal on the server. It's only loaded and hydrated when the user clicks "Open Settings." This saves bandwidth and hydration time.

Prioritizing Hydration: The Critical Path

Map your UI into critical (above the fold, user-facing) and non-critical (below, secondary). Hydrate critical first:

ComponentPriorityReasonHydration Order
HeaderCriticalUsers see it immediately1st
Main contentCriticalCore engagement1st
SidebarMediumUseful but not blocking2nd
Search modalLowOnly opened by user actionDeferred
Analytics trackerLowBackground processDeferred
AdsLowNon-essentialDeferred

Hydration strategy:

  1. Render critical sections as Server Components (no client JS needed).
  2. Wrap non-critical sections in Suspense boundaries.
  3. Use dynamic() with ssr: false for interactive-only components.

Measuring Hydration Impact

Track hydration performance with Web Vitals:

// pages/api/web-vitals.js
export async function handler(req, res) {
const { name, value, id } = req.body;

// Log to analytics: LCP, FID/INP, CLS
console.log(`${name}: ${value}ms (session: ${id})`);

// Alert if INP (interaction delay) is high
if (name === 'INP' && value > 200) {
console.warn('High INP: page is slow to respond to user input');
}

res.status(200).end();
}

// In your layout, measure and report
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function ClientLayout({ children }) {
useReportWebVitals(metric => {
if (metric.name === 'INP') {
fetch('/api/web-vitals', {
body: JSON.stringify(metric),
method: 'POST',
});
}
});

return children;
}

Monitor INP (Interaction to Next Paint) closely. High INP indicates slow hydration or expensive JavaScript.

Code-Splitting for Selective Hydration

Use dynamic() to code-split heavy components:

// app/dashboard/page.js
import dynamic from 'next/dynamic';
import { Suspense } from 'react';

const ChartComponent = dynamic(
() => import('./Chart'), // Only load this when needed
{
loading: () => <div>Loading chart...</div>,
ssr: true, // Render on server but hydrate lazily on client
}
);

export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>

{/* Chart hydrates after header and content */}
<Suspense fallback={<div>Loading chart...</div>}>
<ChartComponent />
</Suspense>
</div>
);
}

The Chart component is bundled separately. If it's 200 KB, splitting it keeps the initial bundle smaller. The chart hydrates only after the main content is interactive.

Pattern: Deferred Interactivity with startTransition

For components that start out as static but become interactive, use startTransition to defer their interactivity:

'use client';

import { Suspense, startTransition, useState } from 'react';

function InteractiveList({ items }) {
const [selected, setSelected] = useState(null);

const handleSelect = (id) => {
startTransition(() => {
setSelected(id);
});
};

return (
<ul>
{items.map(item => (
<li
key={item.id}
onClick={() => handleSelect(item.id)}
style={{ fontWeight: selected === item.id ? 'bold' : 'normal' }}
>
{item.name}
</li>
))}
</ul>
);
}

export function Dashboard() {
const [items, setItems] = useState([]);
const [hydrated, setHydrated] = useState(false);

// After hydration, enable full interactivity
React.useEffect(() => {
setHydrated(true);
}, []);

if (!hydrated) {
return <div>Loading...</div>;
}

return (
<InteractiveList items={items} />
);
}

The list starts as static HTML (rendered on server). Once hydration completes, it becomes interactive. startTransition prevents UI blocking during state updates.

Advanced Pattern: Resumable Components

For very large apps, use resumable components (Server Components that can be resumed on the client). This is the next evolution beyond selective hydration:

// app/dashboard/page.js (Server Component)
async function Dashboard() {
const user = await fetchUser();

return (
<div>
<Header user={user} />
{/* Render the entire dashboard on server, but mark sections for resumption */}
<Suspense fallback={<div>Loading...</div>}>
<InteractiveSection user={user} />
</Suspense>
</div>
);
}

// app/dashboard/InteractiveSection.js ('use client')
'use client';

export function InteractiveSection({ user }) {
// This component is resumable: the server rendered it, the client resumes with minimal JS
const [hover, setHover] = useState(false);

return (
<section
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{hover ? `Welcome back, ${user.name}!` : 'Hover for greeting'}
</section>
);
}

Server renders the full component (minimal JS), client hydrates only the interactive bits. This is the most efficient pattern for large-scale apps.

Real-World Example: E-Commerce Product Page

Here's how selective hydration optimizes an e-commerce product page:

// app/products/[id]/page.js
import { Suspense } from 'react';
import dynamic from 'next/dynamic';
import { fetchProduct, fetchReviews } from '@/lib/data';

// Critical: rendered on server, hydrated immediately
async function ProductInfo({ id }) {
const product = await fetchProduct(id);
return (
<section>
<h1>{product.name}</h1>
<p className="price">${product.price}</p>
<button className="add-to-cart">Add to Cart</button>
</section>
);
}

// Medium priority: lazy-loaded, hydrated after main content
const ReviewSection = dynamic(() => import('./Reviews'), {
loading: () => <div>Loading reviews...</div>,
});

// Low priority: only hydrated on demand
const RelatedProducts = dynamic(() => import('./Related'), {
loading: () => <div>Loading recommendations...</div>,
ssr: false, // Skip server render; only load on client
});

export default function ProductPage({ params }) {
return (
<main>
{/* Priority 1: Product info hydrates immediately */}
<Suspense fallback={<div>Loading product...</div>}>
<ProductInfo id={params.id} />
</Suspense>

{/* Priority 2: Reviews load and hydrate after product */}
<Suspense fallback={<div>Loading reviews...</div>}>
<ReviewSection id={params.id} />
</Suspense>

{/* Priority 3: Related products only load if user scrolls */}
<Suspense fallback={<div>Loading recommendations...</div>}>
<RelatedProducts id={params.id} />
</Suspense>
</main>
);
}

User experience timeline:

  • 0ms: Server sends product info (critical) + review skeleton.
  • 200ms: Client hydrates product info; user can click "Add to Cart".
  • 800ms: Reviews are fetched and hydrated on server.
  • 1200ms: Review section appears on page.
  • Scroll to bottom: Related products component is then loaded and hydrated.

TTI is ~200ms (product interactive), vs. 2+ seconds if everything was bundled together.

Common Pitfall: Over-Deferring Critical Components

Don't defer components that users need immediately:

// Bad: deferring too much
export function Dashboard() {
return (
<Suspense fallback={<div>Loading...</div>}>
<MainContent /> {/* User needs this, shouldn't be deferred */}
</Suspense>
);
}

// Good: defer only secondary content
export function Dashboard() {
return (
<div>
<Suspense fallback={<div>Loading content...</div>}>
<MainContent /> {/* Priority 1 */}
</Suspense>

<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar /> {/* Priority 2: can be deferred */}
</Suspense>
</div>
);
}

Defer based on user value, not implementation convenience.

Key Takeaways

  • Selective hydration defers non-critical JavaScript: Hydrate critical components first, defer heavy/non-essential ones.
  • Suspense boundaries enable priority: Each boundary hydrates in order; use nested boundaries to control sequence.
  • dynamic() with ssr: false skips server render: Useful for interactive-only components (modals, drawers).
  • Measure with INP and TTI: Track interaction delay; optimize until INP < 200ms.
  • Prioritize by user value, not implementation: Critical (above fold, user-facing) hydrates first; secondary (below fold, optional) hydrates after.

Frequently Asked Questions

Does selective hydration work with Client Components?

Yes. Client Components in Suspense boundaries hydrate in order. Use dynamic() to code-split and defer JS loading.

What if a deferred component needs props from a critical one?

Pass props through state or context. The critical component hydrates first, then passes data to deferred ones:

const [productId, setProductId] = useState(null);

return (
<>
<ProductInfo onSelect={setProductId} />
{productId && <RelatedProducts productId={productId} />}
</>
);

Can I measure hydration time per component?

Yes, use React Profiler and Next.js dev tools:

import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} (${phase}): ${actualDuration}ms`);
}

<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>

Is resumable components available now?

Resumable components (RFC) are a future React/Next.js feature (post-2026). Current best practice is selective hydration with dynamic() and Suspense boundaries.

Do I need to rewrite my entire app for selective hydration?

No. Apply it incrementally: start with the critical path (header, main content), then defer secondary sections. Most benefits come from prioritizing the top 10% of components by bundle size.

Further Reading