frontend / rendering modes / 02_hydration_models.md

Hydration Models — Full, Selective, Progressive, Partial

6 min read source

Hydration Models — Full, Selective, Progressive, Partial

TL;DR

Hydration is how server-rendered HTML becomes interactive — React/Vue’s runtime “attaches” to the existing DOM, wires up event handlers, and rebuilds the virtual tree. Full hydration (old default) re-mounts the whole tree at once, blocking the main thread. Selective hydration (React 18+) hydrates <Suspense> boundaries independently. Progressive hydration prioritizes by user interaction. Partial hydration (Astro/Fresh “islands”) only hydrates marked components — the rest stays HTML. Each model trades hydration cost against interactivity.

Interview Q&A

Q: Why does hydration cost matter?

A: Hydration runs JavaScript on the main thread. While it runs:

  • The user can’t interact — clicks queue up but handlers aren’t attached.
  • INP suffers — first interaction after page load is laggy.
  • Bundle parse + compile time stacks on top.

A page that paints in 500ms but takes 2s to hydrate looks ready but isn’t usable. The senior framing: “fast LCP, slow INP” — hydration cost is the most common cause.

Q: Full hydration — what does it do?

A: The default model in React 17 and earlier. The runtime walks the entire DOM tree, builds the virtual tree, attaches handlers — all in one synchronous-ish task.

Problems:

  • A long tree (1000+ components) = a long task (> 100ms easily).
  • Can’t be interrupted; user input waits.
  • The cost is per-page-load.

For small pages, full hydration is fine. For large dashboards, news feeds, complex SPAs — it’s the perf killer.

Q: Selective hydration — what changed?

A: React 18 introduced <Suspense> as a hydration boundary. Each suspended subtree hydrates independently, in any order, parallel to other boundaries.

<Suspense fallback={<HeaderSkel />}>
  <Header />
</Suspense>
<Suspense fallback={<FeedSkel />}>
  <Feed />
</Suspense>

Hydration:

  1. React hydrates outside of boundaries.
  2. Each boundary’s content is hydrated separately as its code/data arrives.
  3. The user can click in already-hydrated parts before others finish.

This breaks the “one long task” into many small ones, each interruptible.

Q: Progressive hydration — automatic prioritization.

A: Layered on top of selective: React 18 dynamically reprioritizes hydration based on user interaction.

User clicks an unhydrated subtree → React fast-tracks that subtree’s hydration so the click handler attaches and runs. Other subtrees wait.

This is automatic — no config. The user’s intent drives the order. Underrated feature; quietly makes hydration feel instant.

Q: Partial hydration (islands architecture).

A: A different architecture: most of the page is static HTML (never hydrates); specific “islands” of interactivity hydrate independently. Astro, Fresh (Deno), Iles, Marko.

---
// Astro file: this code runs at build/server time
import { Counter } from "./Counter.tsx";
---

<html>
  <h1>Title</h1>
  <p>Static content — never ships JS for this.</p>
  <Counter client:visible />   <!-- island — hydrates when visible -->
  <Footer />                    <!-- static — no JS -->
</html>

The client:visible directive tells Astro to lazy-hydrate this component when it scrolls into view. Other directives: client:idle, client:load, client:media="(max-width: 600px)".

Result: a 100% static page with 1-2 islands ships ~5-10 KB of JS instead of the full framework runtime + every component.

Q: How does RSC relate to hydration?

A: Server Components don’t hydrate at all — they render on the server, produce a serialized payload + HTML, and the client doesn’t re-render them. Only client components (the "use client" boundaries) hydrate.

Effect: if 80% of your tree is server components, only 20% hydrates. Hydration cost drops by 80%.

This is structurally similar to islands: “interactive bits hydrate, the rest is static.” Different mechanism (RSC streams server-rendered content; islands ship pre-built static), same outcome: less hydration.

Q: When does hydration mismatch happen, and why is it bad?

A: When the server-rendered HTML differs from what the client would render. React warns; depending on severity, falls back to a full client-side re-render of the mismatched subtree.

Causes:

  • Date.now() / Math.random() in render.
  • Browser-only checks (typeof window !== 'undefined') returning different values server vs client.
  • User-locale formatting (server might use UTC; client uses Pacific).
  • Dynamic CSS-in-JS that hasn’t been server-rendered.
  • HTML mutation by browser extensions (Grammarly, password managers).

Cost: full re-render of the affected subtree. Wasted server rendering. Visible “flash” of content changing.

See ../05_react/hydration.md for fixes.

Q: What’s “no JS until interaction” (Qwik’s resumability)?

A: Qwik takes hydration to its extreme: zero JS executes on page load. The server renders HTML with serialized event listener references; the framework only loads + executes the handler when the user clicks/hovers/etc.

Properties:

  • Instant interactivity (sort of) — handler arrives just-in-time, no upfront hydration.
  • Tiny initial JS — a small runtime resumes from the serialized state.
  • Trade-off: first interaction has a network round trip for the handler code (unless prefetched).

Niche but interesting. Mention if asked about future-of-hydration; not yet mainstream.

Q: Lazy hydration patterns in React.

A: Without RSC or framework support, you can hand-roll:

function LazyHydrate({ when, children }: { when: "visible" | "idle"; children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    if (hydrated) return;
    if (when === "idle") {
      requestIdleCallback(() => setHydrated(true));
    } else if (when === "visible") {
      const obs = new IntersectionObserver(([e]) => {
        if (e.isIntersecting) { setHydrated(true); obs.disconnect(); }
      });
      if (ref.current) obs.observe(ref.current);
      return () => obs.disconnect();
    }
  }, [when, hydrated]);

  return hydrated ? <>{children}</> : <div ref={ref} dangerouslySetInnerHTML={{ __html: serverHtml }} />;
}

Hack-y but works. Use react-lazy-hydration library for production. Astro/Fresh do this natively without the hack.

Q: How do you measure hydration cost?

A:

  • React Profiler — open during page load, see commit timing for the initial hydration.
  • Chrome DevTools Performance — record a page load, look for the long task that’s React’s hydration. Visible as a tall block of “Scripting” work right after the HTML loads.
  • web-vitals — TBT (Total Blocking Time, lab metric) and INP (field) both flag hydration cost.
  • <Profiler> API with id="root" — programmatic timing of the hydration commit.

Watch for: hydration commit > 200ms = serious INP risk. Aim for < 100ms per boundary.

Gotchas / edge cases

  • useLayoutEffect warnings during SSRuseLayoutEffect doesn’t run server-side; React warns. Wrap with useEffect or use useIsomorphicLayoutEffect.
  • CSS-in-JS hydration mismatch — server-rendered styles differ from client-extracted styles → FOUC. Library-specific server-render setup needed.
  • Third-party components without SSR supportCalendar, MapView that touch window blow up server-side. Use next/dynamic({ ssr: false }) or guard with useEffect.
  • Streaming + slow boundary — a <Suspense> boundary that hangs forever holds the connection open. Set timeouts in the data fetcher.
  • Selective hydration order is based on Suspense boundaries — too few boundaries = back to full-tree hydration. Add boundaries deliberately for big subtrees.
  • Browser extensions (Grammarly, etc.) mutate the DOM and trigger hydration warnings the developer can’t fix. Suppress per-element via suppressHydrationWarning.
  • Re-hydration on navigation — SPA-style navigation doesn’t re-hydrate; only the first page load does. Mid-app navigation costs come from rendering new components, not re-hydration.

What a senior is expected to say

  • “Hydration cost is the main reason ‘fast LCP, slow INP’ happens. The page paints fast but the JS to make it interactive takes longer.”
  • “Selective hydration (React 18+) breaks the long task into per-Suspense-boundary chunks; progressive hydration auto-prioritizes the part the user clicks.”
  • “Islands architecture (Astro, Fresh) and RSC both achieve ‘most of the page is static, only interactive parts hydrate’ via different mechanisms.”
  • “Reduce hydration cost: push more to Server Components (RSC), add <Suspense> boundaries to break the tree up, lazy-hydrate off-screen widgets.”
  • “Hydration mismatches cause full re-renders — the cost of SSR you didn’t get to keep. Guard browser-only code with useEffect.”
  • “Measure with React Profiler + Chrome Performance; aim for < 100ms per hydration boundary.”

Cross-references

Further reading