Every client:load directive you add ships JavaScript to every reader. A theme toggle: 3KB. A search dialog: 45KB. A chart library: 120KB. Add three islands with client:load and you have blown past the 200KB initial load budget before the page has any actual content. Most of those readers will never click the search icon. Most will never scroll to the chart. The JavaScript downloaded, parsed, and executed for nothing.
The difference between a Lighthouse 100/100 score and an 85 is not clever optimization tricks. It is understanding which components actually need interactivity — and choosing the right hydration strategy for each.
Two Types of Components
Astro draws a hard line between two component types. Understanding this distinction is the single most important architectural decision you will make.
Static components (.astro files) render at build time. They produce HTML. They ship zero JavaScript. They cannot hold state, respond to clicks, or run effects. They are the default, and that default is the entire performance story.
Interactive islands (.tsx, .svelte, .vue files) are framework components that hydrate on the client. They can hold state, handle events, and run lifecycle effects. They ship framework runtime code plus the component’s own JavaScript.
---// This runs at BUILD TIME only. No JavaScript ships.const navLinks = [ { href: '/', label: 'Home' }, { href: '/blog', label: 'Blog' }, { href: '/about', label: 'About' },];---
<header class="border-b border-[var(--color-border)]"> <nav class="mx-auto flex max-w-3xl items-center justify-between px-6 py-4"> <a href="/" class="font-sans text-xl font-bold">Blogcraft</a> <ul class="flex gap-6"> {navLinks.map(link => ( <li> <a href={link.href} class="text-[var(--color-text-muted)] hover:text-[var(--color-text)]"> {link.label} </a> </li> ))} </ul> </nav></header>This header has loops, conditional logic, and dynamic data — but it is all resolved at build time. The output is pure HTML. A navigation bar does not need useState.
The Four Hydration Directives
When a component genuinely needs client-side JavaScript, Astro provides four directives that control when it hydrates. The choice determines how much JavaScript loads, when it loads, and whether it blocks the critical rendering path.
| Directive | When It Hydrates | JS Impact | Use Case |
|---|---|---|---|
| client:load | Immediately on page load | Blocks FCP. JS downloads and executes before first paint completes. | Components that MUST be interactive before the user can see anything. Almost nothing qualifies. |
| client:visible | When the component enters the viewport | Zero impact until scroll. Uses IntersectionObserver internally. | Charts, animations, TOC highlighter — things below the fold or in the reading flow. |
| client:idle | After page load, during idle time | Deferred via requestIdleCallback. Does not block main thread. | Search dialogs, code playgrounds — heavy components the user might interact with eventually. |
| client:only="react" | Client-side only, no SSR | Component renders entirely on the client. Shows nothing during SSR. | Components that depend on browser APIs or libraries that crash during SSR (Plate editor, certain D3 bindings). |
The decision is not arbitrary. Here is the flowchart:
Walk through this for every interactive component. The default answer is .astro (zero JS). The escalation path is deliberate.
client:load — The Banned Directive
On Blogcraft, client:load is banned. Not discouraged — banned. A shell script runs in the prebuild step and fails the build if any .astro file contains client:load outside the exception list.
Why this extreme? Because client:load downloads, parses, and executes JavaScript before the page finishes rendering. It directly increases First Contentful Paint (FCP) and Interaction to Next Paint (INP). On a content site where the primary action is reading, that cost has zero return for 99% of page loads.
The single exception is ThemeToggle. Without client:load, the theme toggle cannot read the user’s saved preference from localStorage before the first paint, causing a flash of wrong theme (FOWT). That flash is a worse user experience than the 3KB JavaScript cost.
---import ThemeToggle from '@/components/islands/ThemeToggle.tsx';---
<!-- The ONLY client:load on the entire site --><ThemeToggle client:load />Everything else uses client:visible or client:idle.
The Enforcement Script
#!/usr/bin/env bashset -euo pipefail
# Files allowed to use client:loadEXCEPTIONS="BaseLayout.astro"
VIOLATIONS=$(grep -rn "client:load" src/ \ --include="*.astro" \ | grep -v "$EXCEPTIONS" \ || true)
if [ -n "$VIOLATIONS" ]; then echo "ERROR: client:load detected outside allowed files:" echo "$VIOLATIONS" echo "" echo "Use client:visible or client:idle instead." exit 1fi
echo "OK: No unauthorized client:load directives found."client:visible — Hydrate on Scroll
client:visible uses the browser’s IntersectionObserver to detect when a component enters the viewport. The component renders as static HTML during the initial page load. When the user scrolls to it, the JavaScript downloads and the component hydrates into a fully interactive React component.
This is the right directive for anything in the reading flow: charts, animated illustrations, the table-of-contents highlighter that tracks scroll position.
---import Chart from '@/components/islands/Chart.tsx';import TOCHighlighter from '@/components/islands/TOCHighlighter.tsx';import Animation from '@/components/islands/Animation.tsx';---
<!-- Renders static HTML initially, hydrates when scrolled into view --><TOCHighlighter client:visible headings={headings} />
<!-- Chart shows a loading skeleton until hydration --><Chart client:visible data={chartData} type="bar" title="Build time comparison"/>
<!-- Lottie animation starts only when visible --><Animation client:visible src="/animations/deploy-flow.lottie" />The user sees the static HTML (or a skeleton) immediately. There is no layout shift because the component occupies its full space from the initial render. When it scrolls into view, hydration adds interactivity — click handlers, hover states, animation playback.
The Performance Math
A blog post with three client:visible charts loads zero chart JavaScript on initial page load. If the reader finishes the article before scrolling to the third chart, that chart’s JavaScript never downloads at all. Compare this with client:load, which would download all three chart bundles before the reader sees the first paragraph.
client:idle — Hydrate When the Browser Is Free
client:idle defers hydration until the browser’s main thread has no pending work, using requestIdleCallback. The component renders as static HTML, then hydrates during a quiet moment after the page finishes loading.
This is the right directive for components that are always present but rarely used immediately: the search dialog, code playgrounds, comment sections.
---import SearchDialog from '@/components/islands/SearchDialog.tsx';import CodePlayground from '@/components/islands/CodePlayground.tsx';---
<!-- Search button is visible immediately as static HTML. The dialog functionality hydrates during idle time. --><SearchDialog client:idle />
<!-- Playground shows read-only code initially. Edit/run capability loads during idle time. --><CodePlayground client:idle template="react" files={{ '/App.tsx': `export default function App() { return <h1>Hello</h1>;}` }}/>The SearchDialog renders its trigger button as static HTML immediately. The user sees a search icon in the header from the first paint. The actual search index (loaded via Fuse.js), keyboard shortcut handling, and modal overlay load during idle time. On fast connections, this happens within a second of page load. On slow connections, the static button is still visible — it just does not respond to clicks until hydration completes.
client:only — Skip SSR Entirely
Some components cannot server-render. They depend on browser APIs (window, document, localStorage), use libraries that crash during SSR, or require a DOM measurement before first render. client:only="react" tells Astro to skip the component entirely during the static build and render it exclusively on the client.
---import WriteEditor from '@/components/editor/WriteEditor.tsx';export const prerender = false; // SSR route, not static---
<!-- Plate editor cannot server-render — it requires DOM APIs --><WriteEditor client:only="react" />The tradeoff is clear: the component contributes nothing to the initial HTML. The user sees an empty container until JavaScript loads and renders the component. Use this only when there is no alternative.
On Blogcraft, only the Plate-based writing editor uses client:only. It depends on contentEditable, browser selection APIs, and DOM measurement that have no server-side equivalent.
Slots: Composing Without JavaScript
Astro’s <slot /> is the composition primitive for static components. It works like React’s children prop but compiles away entirely — no runtime, no virtual DOM, no reconciliation.
---interface Props { title: string; href: string;}const { title, href } = Astro.props;---
<article class="rounded-lg border border-[var(--color-border)] p-6"> <h3 class="font-sans text-lg font-semibold"> <a href={href}>{title}</a> </h3> <div class="mt-2 text-[var(--color-text-muted)]"> <slot /> </div></article><Card title="Islands Architecture" href="/blog/islands"> <p>Learn when to hydrate and when to stay static.</p> <span class="text-sm">5 min read</span></Card>The <slot /> is replaced with the child content at build time. The output is pure HTML. Named slots work too — <slot name="footer" /> with <div slot="footer"> — giving you full layout composition without a component runtime.
Props Serialization: Astro to React
When you pass data from an .astro file to a React island, Astro serializes the props to JSON and embeds them in the HTML. The island deserializes them during hydration. This means props must be JSON-serializable.
---// This data is fetched at build timeconst chartData = await getChartData();---
<!-- chartData is serialized to JSON in the HTML output --><Chart client:visible data={chartData} title="Framework Bundle Sizes" type="bar"/>What works: strings, numbers, booleans, plain objects, arrays, null. What does not work: functions, class instances, Date objects, Map, Set, symbols. If you need to pass a date, serialize it as an ISO string and parse it in the React component.
// Date objects cannot cross the Astro/React boundary// Pass as string, parse on the clientinterface Props { dateString: string; // ISO 8601}
export default function PostDate({ dateString }: Props) { const date = new Date(dateString); return <time dateTime={dateString}>{date.toLocaleDateString()}</time>;}The Performance Budget
Blogcraft enforces a strict performance budget: less than 10KB of JavaScript on non-interactive pages, and zero main-thread JavaScript on content pages that have no islands.
Here is what that budget looks like in practice:
| Page Type | JS Budget | Islands Allowed | Example |
|---|---|---|---|
| Landing page | 0KB | ThemeToggle only (client:load, ~3KB) | / (home) |
| Blog post (text only) | 0KB | ThemeToggle + TOCHighlighter (client:visible, lazy) | /blog/project-structure |
| Blog post (interactive) | < 150KB total | ThemeToggle + TOC + Chart + CodePlayground | /blog/performance-benchmarks |
| Search page | < 80KB | ThemeToggle + SearchDialog (client:idle) | /search |
| Write page (editor) | No budget — author-only | Plate editor (client:only) | /write |
The write page has no performance budget because it is an author-only tool behind an authentication gate. Readers never load it. Every reader-facing page stays under budget.
Measuring the Budget
Use the browser’s Network tab filtered to JavaScript, or run Lighthouse in CI. The build pipeline includes Lighthouse CI with a 95%+ threshold on all four categories:
module.exports = { ci: { collect: { url: [ 'http://localhost:4321/', 'http://localhost:4321/blog/project-structure/', ], }, assert: { assertions: { 'categories:performance': ['error', { minScore: 0.95 }], 'categories:accessibility': ['error', { minScore: 0.95 }], 'categories:best-practices': ['error', { minScore: 0.95 }], 'categories:seo': ['error', { minScore: 0.95 }], }, }, },};A Lighthouse regression from 100 to 95 passes. A regression to 94 fails the build. This gives headroom for third-party analytics scripts while catching genuine performance regressions.
Common Mistakes
Mistake 1: Making Everything a React Component
The most common island architecture mistake is reaching for React by default. A card grid, a tag list, a breadcrumb trail — none of these need client-side JavaScript. They are static content that should be .astro components.
---// TagList.astro — zero JavaScriptinterface Props { tags: string[];}const { tags } = Astro.props;---
<ul class="flex flex-wrap gap-2"> {tags.map(tag => ( <li> <a href={`/tags/${tag}`} class="rounded-full bg-[var(--color-bg-surface)] px-3 py-1 text-sm"> {tag} </a> </li> ))}</ul>// TagList.tsx — ships React runtime for no reasonexport default function TagList({ tags }: { tags: string[] }) { return ( <ul className="flex flex-wrap gap-2"> {tags.map(tag => ( <li key={tag}> <a href={`/tags/${tag}`} className="rounded-full bg-[var(--color-bg-surface)] px-3 py-1 text-sm"> {tag} </a> </li> ))} </ul> );}Both produce identical HTML. The .astro version costs nothing. The .tsx version ships the React runtime.
Mistake 2: Using client:load for Below-Fold Content
A chart that appears halfway through a 3,000-word article does not need client:load. The reader will not interact with it for at least 30 seconds of scrolling. Use client:visible and the JavaScript loads only when the chart enters the viewport — if it ever does.
Mistake 3: Passing Fetched Data as Props When the Island Could Fetch It
If a React island needs data that changes frequently, do not fetch it in the .astro file and pass it as serialized props. The island will receive stale build-time data. Instead, let the island fetch its own data after hydration.
import { useState, useEffect } from 'react';
export default function LiveStats() { const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => { fetch('/api/stats') .then(r => r.json()) .then(setStats); }, []);
if (!stats) return <StatsSkeleton />; return <StatsDisplay stats={stats} />;}This pattern works with client:visible — the fetch fires only when the component enters the viewport, not on every page load.
Real-World Island Inventory
Here is every interactive island on Blogcraft, the hydration directive it uses, and why.
| Component | Directive | Bundle Size | Justification |
|---|---|---|---|
| ThemeToggle | client:load | ~3KB | Must read localStorage before first paint to prevent theme flash |
| SearchDialog | client:idle | ~45KB (Fuse.js) | Always in header, but search is not the first user action |
| TOCHighlighter | client:visible | ~2KB | Tracks scroll position for active heading — only relevant when the TOC is visible |
| Chart | client:visible | ~120KB (Recharts) | Interactive tooltips and hover states, but only when scrolled into view |
| CodePlayground | client:idle | ~180KB (Sandpack) | Heaviest island. Loads read-only code immediately, edit mode after idle |
| Animation | client:visible | ~15KB (dotlottie) | Lottie playback starts when visible, pauses when out of viewport |
| WriteEditor | client:only="react" | ~250KB (Plate) | Cannot SSR. Author-only route, excluded from performance budget |
Total reader-facing JavaScript on a typical blog post with one chart: ~5KB (ThemeToggle + TOCHighlighter). The chart’s 120KB loads only if the reader scrolls to it. The search dialog’s 45KB loads during idle time. The result: Lighthouse 100/100 on every content page.
Key Takeaways
- The default is zero JavaScript. Start with
.astro. Escalate to React only when the component needs client-side state or event handling. client:loadis banned except forThemeToggle. Enforce this with a prebuild script that fails the build on violations.client:visiblefor in-viewport content — charts, animations, scroll-aware components. JavaScript loads only when the user scrolls to it.client:idlefor present-but-not-urgent — search, code playgrounds. JavaScript loads during browser idle time.client:onlyis a last resort for components that genuinely cannot server-render.- Props must be JSON-serializable. No functions, no class instances, no
Dateobjects across the Astro/React boundary. - Measure and enforce. Lighthouse CI in the build pipeline catches regressions. The
client:loadban script prevents the most common performance mistake.