Migrating from CSR to Partial Hydration Step by Step
When a monolithic client-side rendered app ships a large JavaScript bundle that must parse and execute before users can interact with anything, every page load pays a compulsory main-thread tax. The symptom is a high Time to Interactive combined with sluggish Interaction to Next Paint: the page looks done but feels frozen. This guide gives frontend engineers a phase-gated migration path from that monolithic CSR baseline to partial hydration with streaming SSR, with measurable checkpoints at each phase.
Prerequisites
Before beginning the migration, confirm the following are in place:
Migration Steps
Step 1 β Baseline Profiling & Hydration Cost Quantification
Goal: Produce a quantified hydration cost baseline. Do not change architecture until you can measure what you are changing.
Open Chrome DevTools β Performance β Record a cold load with CPU throttled 4Γ. Filter the flame chart by βMain Threadβ to isolate JavaScript execution. Your target metric is the longest continuous blocking task during the hydration window.
// Instrument hydration duration with PerformanceObserver before touching architecture.
// This snippet should live in your app entry point during the diagnostic phase only.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Log each user-timing measure emitted by the framework's hydration scheduler
console.table({
phase: entry.name,
durationMs: entry.duration.toFixed(2),
startMs: entry.startTime.toFixed(2),
});
}
});
// 'buffered: true' catches marks that fired before the observer registered
observer.observe({ type: 'measure', buffered: true });
For React apps, wrap your root component in <React.Profiler> and log actualDuration for the "mount" phase β that is your hydration cost per component subtree.
Expected output: A table of hydration phases with durations. Any single phase exceeding 50 ms on a mid-range Android device is a blocking bottleneck.
Also measure the raw JavaScript payload cost:
// Estimate parse/compile cost from inline script sizes.
// External scripts require Navigation Timing + Resource Timing APIs for accurate measurement.
const scripts = Array.from(document.scripts);
const inlineKB = scripts.reduce((acc, s) => acc + (s.text?.length ?? 0), 0) / 1024;
console.log(`Inline script: ${inlineKB.toFixed(1)} KB (parse/compile cost correlates ~1ms per KB on mobile)`);
Step 2 β SSR Pipeline Initialization
Goal: Switch from a client-rendered HTML shell to a server-rendered HTML stream that delivers meaningful content before any JavaScript executes.
This is the highest-risk step because non-deterministic rendering breaks the server/client DOM parity that hydration depends on. Fix determinism issues before enabling streaming.
Common non-determinism sources to audit:
new Date()orDate.now()called during render β move to server data propsMath.random()used as element keys or IDs β replace with stable server-generated IDs- Direct
windowordocumentaccess at module scope β guard withtypeof window !== 'undefined'
Once deterministic, enable streaming output. React 18 example:
// server.js β React 18 streaming SSR entry point
import { renderToPipeableStream } from 'react-dom/server';
export function handleRequest(req, res) {
const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
// onShellReady fires once the synchronous shell (above all Suspense boundaries) is ready.
// Send headers and start piping immediately β do not buffer the full response.
onShellReady() {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
pipe(res);
},
onError(err) {
// Log server-side; the client will see a Suspense fallback, not a blank page
console.error('Stream error:', err);
},
});
// Abort after 10 s to prevent zombie streams on slow data dependencies
setTimeout(abort, 10_000);
}
Verify the pipeline is working:
curl -s -D - https://your-app.local/ | head -30
# You should see:
# Transfer-Encoding: chunked
# Content-Type: text/html; charset=utf-8
# ... followed by the opening <html> tag immediately, not after a multi-second delay
Align static fallback markup with the progressive enhancement in modern frameworks strategy: the shell HTML must be meaningful and readable before any hydration script fires.
Step 3 β Component Boundary Delineation & Island Extraction
Goal: Classify every component as static or interactive, then extract interactive components into isolated island modules.
Run the bundle analyser to visualise current dependency entanglement:
# For webpack projects
npx webpack-bundle-analyzer dist/stats.json
# For Vite projects
npx vite-bundle-visualizer
For each component, apply this decision rule:
| Condition | Classification | Action |
|---|---|---|
Has onClick, onChange, or other event handlers |
Interactive | Extract as island |
Uses useEffect, useState, or useReducer |
Interactive | Extract as island |
Reads window, navigator, or localStorage |
Interactive | Extract as island |
| Receives server data and renders it | Static | Strip from client bundle |
| Navigation links, headings, article copy | Static | Strip from client bundle |
After classification, extract islands into their own modules. In Astro, apply hydration directives directly in the template:
---
// src/pages/dashboard.astro
import StaticNav from '../components/StaticNav.astro'; // zero JS
import InteractiveChart from '../components/Chart.jsx'; // island
import CommentThread from '../components/Comments.jsx'; // island
---
In Next.js App Router, use the 'use client' boundary to isolate interactive subtrees, keeping as much as possible in Server Components:
// app/dashboard/page.tsx β Server Component (default)
// No client JS shipped for this file
import { InteractiveChart } from './InteractiveChart'; // 'use client' internally
export default async function DashboardPage() {
// Data fetching happens on the server; only the serialised props cross the boundary
const data = await fetchMetrics();
return (
<main>
<h1>Dashboard</h1> {/* static β no JS */}
<InteractiveChart data={data} /> {/* hydrated island */}
</main>
);
}
Step 4 β Partial Hydration Directive Mapping
Goal: Schedule each islandβs hydration based on viewport position and user intent, not the page load event.
The hydration directive hierarchy, from highest to lowest main-thread cost:
| Directive | Trigger | Use case | INP risk |
|---|---|---|---|
client:load / eager |
Immediately on load | Above-fold CTAs, search inputs | High β fires during TTI window |
client:visible |
IntersectionObserver entry |
Below-fold widgets, charts | Low β deferred until needed |
client:idle |
requestIdleCallback |
Comments, social embeds | Very low β only during idle |
client:media |
CSS media query match | Sidebar panels, mobile menus | None until breakpoint |
Monitor hydration scheduling during development to confirm the directive hierarchy is respected:
// Instrument IntersectionObserver-triggered hydration to verify timing.
// Add this to your island's hydration entry point in development mode only.
if (import.meta.env.DEV) {
const mark = `hydrate:${document.currentScript?.dataset.islandId ?? 'unknown'}`;
performance.mark(mark + ':start');
// After hydration completes, your framework calls a callback β hook into it here
requestAnimationFrame(() => {
performance.mark(mark + ':end');
performance.measure(mark, mark + ':start', mark + ':end');
});
}
For React/Next.js without first-class island directives, replicate the client:visible pattern using next/dynamic and a custom IntersectionObserver wrapper:
// components/LazyIsland.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
// The heavy component is not included in the initial bundle
const HeavyWidget = dynamic(() => import('./HeavyWidget'), {
ssr: false,
loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
});
export function LazyIsland() {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ rootMargin: '200px' } // pre-load 200px before viewport entry
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
// Render a stable placeholder until intersection fires β avoids CLS
return <div ref={ref}>{visible ? <HeavyWidget /> : <div className="h-64" />}</div>;
}
Step 5 β State Serialization & Hydration Boundary Debugging
Goal: Ensure the server-to-client state payload is fully serializable and matches what the client expects at hydration time.
Inspect the payload your framework injects into the page:
// Check what state the server is embedding for client pickup.
// window.__NEXT_DATA__ is Next.js; other frameworks use different keys.
console.log(JSON.stringify(window.__NEXT_DATA__?.props, null, 2));
The three most common serialization failures and their fixes:
Date objects β JSON.stringify converts them to ISO strings but JSON.parse returns plain strings, not Date instances. The server and client then disagree on the type.
// Custom serializer that round-trips Date objects safely
function serializeState(state) {
return JSON.stringify(state, (_key, value) => {
if (value instanceof Date) {
// Tag the value so the client deserializer can reconstruct it
return { __type: 'Date', iso: value.toISOString() };
}
if (typeof value === 'function') {
// Functions cannot cross the serialization boundary β strip them
return undefined;
}
return value;
});
}
function deserializeState(json) {
return JSON.parse(json, (_key, value) => {
if (value?.__type === 'Date') return new Date(value.iso);
return value;
});
}
Circular references β JSON.stringify throws synchronously. Use structuredClone to detect and eliminate cycles before serialization:
// structuredClone throws on circular refs β catch it to locate the problem object
try {
const clean = structuredClone(serverState);
return JSON.stringify(clean);
} catch (err) {
console.error('Circular reference in state payload:', err);
throw err;
}
undefined values β JSON.stringify silently drops object keys with undefined values, producing a server payload that differs from the initialized client state. Replace undefined with null or use a default value.
Step 6 β Streaming SSR & Suspense Coordination
Goal: Nest Suspense boundaries to keep layout stable during progressive streaming and prevent island hydration from racing ahead of unresolved chunks.
A shallow Suspense tree β one boundary at the page root β degrades to a single blocking chunk. Nest boundaries at both the route level and the island level:
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { ProductHeader } from './ProductHeader'; // fast β from cache
import { ReviewSection } from './ReviewSection'; // slow β live DB query
import { RecommendedItems } from './RecommendedItems'; // slowest β ML ranking
export default function ProductPage({ params }) {
return (
<>
{/* Shell: sends immediately, unblocked by slow sections */}
<ProductHeader id={params.id} />
{/* Each Suspense boundary flushes independently when its data resolves */}
<Suspense fallback={<ReviewSkeleton />}>
<ReviewSection id={params.id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendedItems id={params.id} />
</Suspense>
</>
);
}
Preload critical assets for above-fold islands to prevent a hydration penalty on the first visible interaction:
<!-- In your <head> β tells the browser to fetch the island chunk at high priority -->
<link rel="modulepreload" href="/_next/static/chunks/InteractiveChart.js" />
The target streaming efficiency metric is: TTI β TTFB < 200 ms. If the gap is larger, a Suspense boundary is blocking the shell or a synchronous data fetch is holding the stream open.
Step 7 β Validation, Regression Testing & INP Optimization
Goal: Confirm the migration improved every baseline metric, catch regressions in CI, and eliminate residual main-thread blocking.
Run Lighthouse against the migrated page under simulated mobile conditions:
# Lighthouse CLI β mobile preset, three runs averaged
npx lighthouse https://your-app.local/ \
--preset=perf \
--emulated-form-factor=mobile \
--throttling-method=simulate \
--output=json \
--output-path=./lh-report.json \
--chrome-flags="--headless"
# Extract the metrics you care about
node -e "
const r = require('./lh-report.json').audits;
console.table({
LCP: r['largest-contentful-paint'].displayValue,
TBT: r['total-blocking-time'].displayValue,
CLS: r['cumulative-layout-shift'].displayValue,
TTI: r['interactive'].displayValue,
});
"
Measure main-thread idle time after hydration completes to confirm you are not leaving long tasks behind:
// Run in DevTools console on the migrated page to verify idle headroom
const start = performance.now();
requestIdleCallback((deadline) => {
const idleAt = performance.now() - start;
console.log(`First idle: ${idleAt.toFixed(0)} ms | Remaining budget: ${deadline.timeRemaining().toFixed(0)} ms`);
});
Validate INP programmatically using the web-vitals library:
import { onINP } from 'web-vitals';
// Log every interaction that contributes to INP β look for values above 200 ms
onINP((metric) => {
console.warn(`INP: ${metric.value} ms | Rating: ${metric.rating}`);
// metric.entries[0].target reveals which element triggered the slow interaction
console.log('Slow element:', metric.entries[0]?.target);
}, { reportAllChanges: true });
Stress-test on constrained hardware: Chrome DevTools β Performance β set CPU to 6Γ slowdown + Network to Fast 3G. A migration that passes on a developer MacBook but fails for users on mid-range Android devices means the hydration boundaries are still too coarse.
Finally, decommission legacy CSR entry points. Remove the old ReactDOM.render or createRoot calls that hydrated the entire document, delete the monolithic bundle entry, and update your build configuration to exclude any now-unused client runtime code.
Performance Expectations After Migration
| Metric | Typical improvement | How to verify |
|---|---|---|
| Initial JS parse/compile cost | 40β70% reduction | Bundle analyser before/after diff |
| Time to Interactive | 300β800 ms faster | Lighthouse TTI audit |
| Interaction to Next Paint | 20β40% lower input latency | web-vitals onINP + Chrome UX Report |
| Heap allocation at load | 15β25% reduction | DevTools Memory β Heap Snapshot diff |
| Cumulative Layout Shift | Near-zero (with skeleton placeholders) | Lighthouse CLS audit |
Migration Diagram
Troubleshooting
Hydration mismatch warnings (Hydration failed / checksum mismatch)
The server-rendered DOM differs from what React expects on the client. Locate the diverging node using React DevTools β Components β highlight re-renders. Common fix: any code that reads browser-only globals (window, navigator, localStorage) or produces non-deterministic output (Date.now(), Math.random()) must run inside useEffect or be guarded by typeof window !== 'undefined' so it never executes during the server render pass.
Streaming stalls β page freezes mid-load
A Suspense boundary is waiting for a data dependency that never resolves within the timeout window. Add an explicit timeout to your data fetching:
// Abort slow fetches that would hold the stream open indefinitely
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5_000);
const data = await fetch('/api/slow-data', { signal: controller.signal })
.finally(() => clearTimeout(timeout));
Nest a separate Suspense boundary around the slow section so the rest of the page streams without waiting for it.
INP regression after migration β interactions feel slower than before
Islands hydrating in the wrong order can cause event-listener gaps where clicks land before the handler attaches. Audit hydration order using the PerformanceObserver instrumentation from Step 1. Promote critical interactive elements from client:idle or client:visible to client:load if they are above the fold and likely to receive early interaction. Check also for duplicate event listeners accumulated across route transitions β use Chrome DevTools β Memory β Heap Snapshot and filter for EventListener counts.
Related
- Understanding Partial Hydration β the foundational mechanics of what partial hydration actually does at the runtime level, and how to measure its overhead in React.
- How to Calculate Hydration Overhead in React β step-by-step instrumentation for isolating which component subtrees are costing the most on the main thread.
- Implementing Suspense Boundaries in Next.js β the Next.js App Router specifics of nesting Suspense boundaries for optimal streaming chunk delivery.
- Comparing Hydration Strategies Across Next.js and Astro β a side-by-side comparison of directive systems and runtime costs to help you choose the right approach for your stack.
β Back to Progressive Enhancement in Modern Frameworks