Frontend / Rendering modes / 02_hydration_models.md

Hydration Models — Full, Selective, Progressive, Partial

Updated 6 interview angles 7 min read source
On this page14
  1. Why does hydration cost matter?
  2. Full hydration — what does it do?
  3. Selective hydration — what changed?
  4. Progressive hydration — automatic prioritization.
  5. Partial hydration (islands architecture).
  6. How does RSC relate to hydration?
  7. When does hydration mismatch happen, and why is it bad?
  8. What’s “no JS until interaction” (Qwik’s resumability)?
  9. Lazy hydration patterns in React.
  10. How do you measure hydration cost?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Hydration Models — Full, Selective, Progressive, Partial

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.

Why does hydration cost matter?

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.

Full hydration — what does it do?

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.

Selective hydration — what changed?

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

tsx
<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.

Progressive hydration — automatic prioritization.

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.

Partial hydration (islands architecture).

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

astro
---
// 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.

How does RSC relate to hydration?

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.

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

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 Hydration — Selective, Progressive, Mismatches for fixes.

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

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.

Lazy hydration patterns in React.

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

tsx
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.

How do you measure hydration cost?

  • 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

  • useLayoutEffect warnings during SSR — useLayoutEffect 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 support — Calendar, 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.

Further reading

Interview angle 6

  • “Why does a page paint fast and then feel dead?” - hydration. The HTML arrived and rendered, then the client had to download, parse and run the JavaScript to attach React, and every interaction during that window queued. It is the main cause of good LCP with bad INP.
  • “What does selective hydration do about it?” - it breaks one long task into per-Suspense-boundary chunks that can be interrupted, and it prioritises the boundary the user actually clicked. So the part being touched becomes interactive first rather than whatever came first in document order.
  • “Islands or RSC?” - two mechanisms for the same goal: most of the page is static and only the interactive parts ship JavaScript. Islands make the boundary explicit in the framework, RSC infers it from the component graph and the "use client" directive.
  • “How would you actually reduce hydration cost?” - move what you can to Server Components so it never enters the client bundle, add Suspense boundaries so the work is splittable, and lazy-hydrate off-screen widgets. Reordering imports does not help; shipping less JavaScript does.
  • “What does a hydration mismatch cost?” - the whole benefit. React discards the server markup for that subtree and re-renders it on the client, so you paid for SSR and did not keep it. Guard browser-only reads behind useEffect.
  • “How do you measure it?” - React Profiler for per-boundary commit time and the Chrome Performance panel for the long task itself. Under about 100ms per boundary keeps the tree interruptible.