frontend / react / hydration.md

Hydration — Selective, Progressive, Mismatches

6 min read source

Hydration — Selective, Progressive, Mismatches

TL;DR

After the server sends HTML, the browser needs to attach React’s runtime to the existing DOM — that’s hydration. React doesn’t re-render the DOM; it reconciles its virtual tree against the existing markup and wires up event handlers and state. Modern React supports selective hydration (boundaries hydrate independently) and progressive/streaming hydration (priority based on user interaction). The senior pain points: hydration mismatches (server HTML ≠ client render → bug), hydration cost (large trees block INP), and the model differences between hydrate / hydrateRoot / RSC streaming.

Interview Q&A

Q: What is hydration?

A: The browser receives server-rendered HTML, paints it (fast FCP/LCP), then downloads + runs JS. React’s hydrateRoot walks the existing DOM, builds its component tree, and attaches event handlers + state without re-creating nodes. The result: the user sees content immediately and the page becomes interactive once hydration finishes.

// React 18+
import { hydrateRoot } from "react-dom/client";

hydrateRoot(document.getElementById("root")!, <App />);

(Old API was ReactDOM.hydrate — React 18+ uses hydrateRoot.)

The hydration phase is synchronous-ish — React walks the tree, runs each component’s render, compares to the DOM. While it’s running, the main thread is blocked → input is unresponsive. That’s the cost.

Q: What’s a hydration mismatch and why is it a bug?

A: When the HTML the server rendered doesn’t match what React renders on the client during hydration. React logs a warning (in dev) and falls back to a full re-render of the affected subtree.

Symptoms:

  • Hydration warning in console.
  • A visual “flash” as React re-renders.
  • Lost interactivity briefly.
  • Inconsistent state.

Common causes:

  • Time / random values rendered server-side: <span>{Date.now()}</span> — different on server vs client.
  • Branching on window / document in the render path without guarding for SSR.
  • Browser-specific rendering (locale formatting, timezone differences).
  • Conditional rendering based on cookies that the server can see but the initial client render can’t.
  • Third-party browser extensions modifying the HTML before React hydrates.

Fixes:

  • Move time/random to useEffect so it runs on the client only:
    const [now, setNow] = useState(0);
    useEffect(() => setNow(Date.now()), []);
    return <span>{now}</span>;
  • useSyncExternalStore with a stable server snapshot for browser-only state.
  • suppressHydrationWarning on the specific element where mismatch is expected (<time suppressHydrationWarning>{Date.now()}</time>) — narrow scope, not a blanket fix.
  • Render the SSR-safe version first, then update on useEffect:
    const [mounted, setMounted] = useState(false);
    useEffect(() => setMounted(true), []);
    if (!mounted) return <SSRPlaceholder />;
    return <ClientOnlyFeature />;

Q: What’s selective hydration?

A: React 18 introduced the ability to hydrate Suspense boundaries independently, in any order. The shell hydrates first; suspended subtrees hydrate as their code/data is ready, in parallel.

Before: one giant blocking hydration of the whole tree. After: hydrate the header, hydrate the sidebar, hydrate the feed — each separately, each interactive when ready.

<Suspense fallback={<HeaderSkeleton />}>
  <Header />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>

Each <Suspense> boundary is a hydration unit. The user can click on the header before the feed has hydrated.

Q: What’s progressive hydration?

A: A priority model on top of selective hydration: React 18+ hydrates on demand based on user interaction. If the user clicks an unhydrated subtree, React reprioritizes that subtree to hydrate first.

So a user clicking a button inside the sidebar — even if the feed was hydrating — pauses the feed work and races to make the sidebar interactive. This is automatic; you don’t configure it.

Q: What’s the cost of hydration on INP?

A: Real. Hydrating a big tree is a long task on the main thread. Effects:

  • Initial click after page load can lag — the user sees content but the handler isn’t attached yet.
  • Total Blocking Time spikes during hydration.

Mitigations:

  • Smaller client trees — push more into Server Components (RSC) where they don’t hydrate at all.
  • More <Suspense> boundaries — split the hydration into smaller chunks; selective hydration runs them piece-by-piece.
  • Defer non-critical componentsnext/dynamic({ ssr: false }) for chat widgets, dashboards-below-fold, etc.
  • Reduce dependencies that ship to the client — RSC ship zero JS for themselves.

The mantra: less client JavaScript = less hydration = faster INP.

Q: hydrateRoot vs createRoot — when each?

A:

Where
createRoot(container, <App />) CSR — no SSR. React renders from scratch.
hydrateRoot(container, <App />) SSR / SSG — React attaches to existing markup.

Use hydrateRoot whenever the HTML was server-rendered. Use createRoot for pure SPAs.

Q: How does RSC streaming hydration differ?

A: With RSC + Suspense streaming (Next.js App Router):

  1. Server starts the response with the shell (everything not suspended).
  2. As suspended boundaries resolve, the server streams their RSC payload + HTML.
  3. Client receives the shell first; renders it. Receives more chunks; React stitches them in.
  4. Hydration happens per boundary as each arrives.

The user can see the page filling in piece by piece, with each section becoming interactive as soon as it lands. The wire protocol does the streaming; React handles the rest.

Q: How do you detect hydration issues in production?

A:

  • Dev console warnings during local testing.
  • onRecoverableError in hydrateRoot callback — fires on mismatches that React recovers from:
    hydrateRoot(container, <App />, {
      onRecoverableError(err) { reportToAnalytics(err); },
    });
  • Synthetic monitoring — Lighthouse/Playwright in CI catch some.
  • CLS / INP regressions in field data often trace to hydration issues.

Q: What’s “islands architecture”?

A: A pattern where the page is mostly static HTML with small “islands” of interactivity that hydrate independently. Frameworks: Astro, Fresh, Iles. Each island ships only its own JS; the rest is plain HTML.

In React land, RSC + selective hydration is essentially the same idea: server components are the static HTML, client components are the islands.

The senior framing: “the goal is to make the page mostly not require client JS, and let the parts that do require it ship in tiny pieces.”

Gotchas / edge cases

  • Server-rendered HTML must match client tree exactly — whitespace, attributes, element order. The mismatch warning is for a reason.
  • Browser extensions inject elements (Grammarly, password managers) and trigger hydration warnings the developer can’t fix. Suppress the specific elements or accept the noise.
  • useLayoutEffect warnings during SSRuseLayoutEffect doesn’t run on server, only client. Wrap with useEffect or use the useIsomorphicLayoutEffect pattern.
  • Two-pass rendering pattern (mounted flag + useEffect to flip) avoids mismatch but costs a re-render. Use sparingly.
  • Date.now()/Math.random() in render = mismatch — guaranteed.
  • CSS-in-JS libraries that compute styles at runtime can flash unstyled content (FOUC) during hydration unless they ship a server-render strategy.
  • Hydration of large lists is brutal — virtualize before SSR if possible, or accept the long task.

What a senior is expected to say

  • “Hydration is React attaching to server HTML — not re-rendering, just reconciling and wiring up handlers. It’s the cost of SSR’s faster paint.”
  • “Hydration mismatches happen when server HTML differs from client render. Causes: time/random in render, browser-only state, conditional rendering on window. Fix by moving to useEffect or rendering the SSR-safe version first.”
  • “Selective + progressive hydration (React 18+) lets <Suspense> boundaries hydrate independently and in priority order — clicking an unhydrated subtree fast-tracks it.”
  • “Hydration cost is what affects INP — long blocking task on the main thread. Smaller client trees (move to RSC) + more <Suspense> boundaries split the cost.”
  • “RSC + streaming is the modern model: server streams shells then suspended chunks; each hydrates per arrival. Islands architecture by another name.”

Cross-references

Further reading