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.
<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.
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 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:
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 withid="root"— programmatic timing of the hydration commit.
Watch for: hydration commit > 200ms = serious INP risk. Aim for < 100ms per boundary.
Gotchas
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.
Related
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/
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
Suspenseboundaries 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.