Hydration Performance
Astro ships zero JavaScript by default. Every kilobyte of client-side JS you add is a deliberate, opt-in cost paid through a client:* directive on an interactive island. Hydration performance is the discipline of shipping the least JavaScript that still keeps the page feeling responsive — which directly moves Core Web Vitals like INP (Interaction to Next Paint) and TBT (Total Blocking Time). This page shows how to pick the right directive for each component so the main thread stays free when it matters most.
Why hydration costs what it costs
Hydration is the process of attaching event listeners and reactive state to server-rendered HTML so a component becomes interactive in the browser. The HTML is already painted, but until hydration runs, clicks and inputs do nothing. Hydration has three costs: downloading the framework + component JS, parsing/compiling it, and executing it to wire up the DOM. Each cost blocks the main thread, and a blocked main thread is exactly what INP and TBT penalize.
Astro’s islands architecture means each interactive component hydrates independently. A heavy <Carousel /> hydrating below the fold should never delay a <SearchBox /> in the header. The directive you choose controls when each island pays its cost.
The fastest island is the one that never hydrates. Before reaching for a directive, ask whether the component can stay static HTML — Astro renders frameworks to HTML at build time for free.
The directives ranked by cost
Directives differ in when they trigger hydration, which determines how much they compete with the user’s initial interactions.
| Directive | Hydrates | Main-thread impact | Best for |
|---|---|---|---|
| (none) | Never | Zero | Static content, formatting, layout |
client:idle | When the browser is idle (requestIdleCallback) | Deferred | Low-priority widgets above the fold |
client:visible | When it scrolls into view (IntersectionObserver) | Deferred until needed | Anything below the fold |
client:media | When a media query matches | Conditional | Mobile-only or desktop-only UI |
client:load | Immediately on page load | Highest — competes with paint | Critical, always-needed interactivity |
client:only | In the browser only (no SSR) | High + no static fallback | Components that depend on browser APIs |
The rule of thumb: client:load is the most expensive choice because it competes with the initial render. Reserve it for things the user interacts with within the first second.
Defer everything you can
The single biggest win is replacing client:load with a lazier directive. Below-the-fold components should almost always use client:visible.
---
import Newsletter from "../components/Newsletter.jsx";
import RelatedPosts from "../components/RelatedPosts.svelte";
import HeaderSearch from "../components/HeaderSearch.vue";
---
<!-- Critical: user may search instantly -->
<HeaderSearch client:load />
<article>
<slot />
</article>
<!-- Below the fold: don't pay until it's near the viewport -->
<RelatedPosts client:visible />
<!-- Low priority, can wait for an idle moment -->
<Newsletter client:idle />
You can give client:visible a rootMargin so hydration starts slightly before the island scrolls in, eliminating the perceptible lag without front-loading the cost.
<RelatedPosts client:visible={{ rootMargin: "200px" }} />
Conditional hydration with client:media
client:media hydrates only when a CSS media query matches, so JS for a feature that exists on just one breakpoint never loads elsewhere. A mobile slide-out menu, for example, never costs desktop users anything.
---
import MobileMenu from "../components/MobileMenu.jsx";
---
<MobileMenu client:media="(max-width: 50rem)" />
Measuring the payload
Verify your choices by inspecting the actual JS shipped. Astro reports the built assets, and you can audit per-route bundles.
npm run build -- --verbose
npx astro build && du -sh dist/_astro/*.js | sort -h
Output:
2.1K dist/_astro/HeaderSearch.4f8a.js
8.7K dist/_astro/client.b21c.js
14K dist/_astro/RelatedPosts.9d3e.js
If a component appears in the bundle but you expected it to be static, it has a stray client:* directive. Remove it. Pair this with a Lighthouse run to confirm TBT and INP improvements.
Push work to the server with server islands
When a component is dynamic but not interactive — a personalized greeting, a cart count, a recommendation block — a server island renders it on the server per-request and streams it in, shipping no client JS at all. This sidesteps hydration entirely while keeping the rest of the page static and cacheable.
---
import Avatar from "../components/Avatar.astro";
---
<Avatar server:defer>
<div slot="fallback" class="skeleton" />
</Avatar>
The static shell is delivered (and CDN-cached) instantly, while the dynamic island fills in without a hydration cost on the client.
Best practices
- Default to no directive — keep components as static HTML unless they genuinely need browser-side interactivity.
- Reserve
client:loadfor the few elements users touch in the first second; move everything else toclient:visibleorclient:idle. - Use
client:visiblewith a smallrootMarginfor below-the-fold islands to hide the hydration delay. - Scope breakpoint-specific UI with
client:mediaso other viewports never download that JS. - Prefer
server:defer(server islands) over client hydration for dynamic-but-non-interactive content. - Keep islands small and focused — a giant island forces an all-or-nothing hydration cost; split it into independently hydrating pieces.
- Measure after every change: audit
dist/_astro/*.jssizes and watch Lighthouse TBT/INP, not just bundle totals.