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:
- React hydrates outside of boundaries.
- Each boundary’s content is hydrated separately as its code/data arrives.
- 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 withid="root"— programmatic timing of the hydration commit.
Watch for: hydration commit > 200ms = serious INP risk. Aim for < 100ms per boundary.
Gotchas / edge cases
useLayoutEffectwarnings during SSR —useLayoutEffectdoesn’t run server-side; React warns. Wrap withuseEffector useuseIsomorphicLayoutEffect.- 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,MapViewthat touchwindowblow up server-side. Usenext/dynamic({ ssr: false })or guard withuseEffect. - 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
- React hydration deeper: ../05_react/hydration.md
- React Server Components: 03_rsc_deeper.md
- Streaming SSR practical: 04_streaming_ssr_with_suspense.md
- Islands architecture: 06_islands_architecture.md
- Core Web Vitals (INP target): ../15_performance/01_core_web_vitals.md
Further reading
- React 18 — Suspense + selective hydration: https://github.com/reactwg/react-18/discussions/37
- web.dev — Hydration: https://web.dev/articles/rendering-on-the-web#rehydration
- Astro — Islands Architecture: https://docs.astro.build/en/concepts/islands/
- Qwik — Resumable apps: https://qwik.dev/docs/concepts/resumable/