How do you handle SSR + hydration in complex apps
Hydration is the client running React to attach handlers to server HTML. In complex apps: prevent mismatches (same data, same time, same flags on server + client); code-split + lazy-hydrate heavy below-the-fold parts; use Suspense + streaming so hydration is incremental; use RSC to skip hydration for non-interactive UI; defer non-critical to `requestIdleCallback`. Mismatches usually mean impure render.
Hydration in a real app is rarely "just hydrateRoot and you're done." Complex apps hit mismatches, slow hydration, and INP regressions. Handling it well means preventing mismatches and breaking up hydration work.
1. What hydration is
Server sends HTML. Client downloads the JS bundle. React runs the same component tree on the client and attaches event handlers and refs to the existing HTML. If the rendered output matches, React reuses the HTML; if not, you get a mismatch warning (and React 18 may discard the server HTML for that subtree).
2. Common mismatch causes
Time / dates rendered differently
<p>{new Date().toLocaleString()}</p> // server time ≠ client timeFix: format on the server with a stable locale/timezone, or render a placeholder server-side and update on mount.
Random ids
const id = useMemo(() => Math.random(), []);Fix: useId() (React 18) — deterministic across server and client.
Browser-only globals
const width = window.innerWidth; // crashes server, mismatches clientFix: guard with typeof window !== "undefined" or move into useEffect.
Feature flags
If server and client read feature flags from different sources (env vs cookie), they diverge. Fix: pass flags from server to client deterministically (props, serialized state).
Conditional rendering on user agent / locale
Same root cause as above — server can't know all client conditions.
Time-of-day / random ad slot
Don't render server-side; defer to useEffect.
3. Suppressing intentional mismatches
For rare cases where the difference is intentional (e.g., showing local time only client-side), wrap the element:
<span suppressHydrationWarning>{clientOnlyTime}</span>Use sparingly — it suppresses warnings but mismatches still cost.
4. Breaking up hydration
Hydrating a large app is one long task by default — bad for INP. Mitigations:
Code split per route
React.lazy for route-level components. The initial bundle is smaller; hydration is smaller.
Lazy hydration / island architecture
Astro popularized this: only interactive components hydrate; static content ships zero JS.
In React: use React Server Components (in Next.js App Router) — server components don't hydrate at all.
Selective hydration via Suspense
React 18's hydrateRoot + Suspense lets React hydrate the most important parts first; below-the-fold parts can wait. Combine with streaming SSR.
Defer non-critical components
const HeavyChart = lazy(() => import("./HeavyChart"));
// Or with explicit visibility:
const [show, setShow] = useState(false);
useEffect(() => {
const obs = new IntersectionObserver(([e]) => e.isIntersecting && setShow(true));
obs.observe(ref.current);
return () => obs.disconnect();
}, []);5. Streaming SSR + Suspense
Server flushes the shell HTML immediately, then sends slower data widgets as they resolve. React on the client hydrates the shell first, then progressively hydrates the streamed parts.
<Suspense fallback={<Skeleton />}>
<SlowDataWidget />
</Suspense>Avoid blocking the entire SSR on the slowest data fetch.
6. Pass data, don't re-fetch
If the server fetched user data to render, embed it in the HTML (<script>window.__INITIAL_STATE__ = {...}</script>) and read on the client — don't re-fetch the same data.
React Query has Hydration utilities for this; Next.js handles it automatically.
7. Watch INP after hydration
Hydration is a long task → bad TBT/INP. Tools:
- Long-task observer to detect hydration spikes.
- web-vitals for INP in production.
- Profile a hydration with React Profiler.
8. RSC eliminates hydration for parts
React Server Components render on the server and ship serialized output — no client JS, no hydration. Only the components you mark as client components hydrate. For large apps with lots of static content, this is the biggest hydration optimization available.
9. Patterns to avoid
- Big monolithic hydration root — split.
- Re-fetching data on the client that the server already had — hydrate state.
- Random / time-dependent values in render — drift causes mismatches.
- Browser globals at the top level — guard or defer.
Interview framing
"Hydration is the client running React to attach handlers to server HTML. In complex apps the two problems are mismatches and cost. Mismatches come from impure render: time, dates, random ids, browser globals, feature flags that differ between server and client. Use useId for stable ids, format with a stable locale on the server, guard browser globals, and serialize feature flags. Cost — hydration as one long task — kills INP. Mitigate with route-level code splitting, lazy hydration of below-the-fold widgets, Suspense + streaming SSR so slow widgets don't block the shell, and especially React Server Components which skip hydration entirely for non-interactive parts. And hydrate state from the server payload — don't re-fetch what was already rendered."
Follow-up questions
- •What causes a hydration mismatch?
- •Why is hydration expensive in big apps?
- •How does RSC change the hydration cost equation?
- •What's selective hydration in React 18?
Common mistakes
- •Random / time / Math.random in render.
- •window/document at top level.
- •Suppressing hydration warnings instead of fixing.
- •Re-fetching what the server already rendered.
- •Big monolithic hydration roots.
Performance considerations
- •Hydration cost = TBT / INP problem. Smaller bundles + lazy hydration + RSC are the levers.
Edge cases
- •Feature flag rollout across server/client.
- •Different user agent server vs client.
- •Hydrating with a different prop subtree (route mismatch).
Real-world examples
- •Next.js App Router with RSC.
- •Remix with route-level data + streaming.
- •Astro islands.