Astro Islands and Client Directives
Astro’s partial hydration model gives performance engineers precise control over which components ship JavaScript, when that JavaScript executes, and which network requests it triggers. For teams migrating from monolithic client-side bundles, the payoff is significant: only annotated, interactive regions ever load a framework runtime. Everything else ships as static HTML. This page explains how client directives implement that model at the runtime level, how to pair them with Astro’s streaming SSR pipeline, and how to diagnose the failure modes that cost teams their Core Web Vitals gains.
Concept Definition & Scope
An Astro island is a framework component — React, Vue, Svelte, Preact, Solid, or plain JavaScript — that Astro server-renders to static HTML and marks for selective client-side reactivation. The client directive on the component tag is the hydration contract: it tells Astro’s runtime when to fetch the component’s JavaScript bundle, how to schedule its execution, and which browser signal triggers the transition from inert HTML to live component.
What is in scope here: the five built-in directives (client:load, client:idle, client:visible, client:media, client:only), their execution models, prop serialisation constraints, multi-framework island composition, and integration with Astro’s streaming renderer.
What is out of scope: Astro’s build-time static generation pipeline, edge middleware, content collections, and the Astro DB integration. Those topics belong to the parent Framework-Specific Islands & Streaming SSR section.
The <astro-island> Custom Element
Every hydrated component becomes an <astro-island> custom element in the rendered DOM. The element carries all the metadata the client runtime needs to reconstruct the component without a round-trip:
<!-- Rendered DOM output — Astro injects this during SSR -->
<astro-island
uid="0"
component-url="/_astro/ReactCounter.abc123.js" <!-- hashed chunk URL -->
component-export="default"
renderer-url="/_astro/client.react.js" <!-- framework adapter -->
props='{"cartId":"abc-123","threshold":0.25}' <!-- JSON-serialised props -->
ssr <!-- server HTML is already present -->
client="visible" <!-- directive = IntersectionObserver -->
opts='{"name":"ReactCounter","value":true}'
>
<!-- Server-rendered HTML fallback — visible immediately, before JS loads -->
<div class="counter-wrapper">Count: 0</div>
</astro-island>
The <astro-island> element is registered as a custom element in the framework adapter bundle. When it upgrades, it reads the client attribute, selects the appropriate browser API (IntersectionObserver, requestIdleCallback, matchMedia), and defers the actual hydration call until that signal fires.
Hydration Lifecycle Diagram
The following diagram traces the lifecycle of a single client:visible island from server render to live component, alongside the concurrent streaming of HTML chunks.
Technical Mechanics
Client Directive Taxonomy
Each directive maps to a distinct browser scheduling API. The choice determines main-thread impact, network timing, and the hydration window available to the framework runtime.
| Directive | Browser trigger | Network priority | Main-thread cost | Canonical use case |
|---|---|---|---|---|
client:load |
DOMContentLoaded |
High — parallel with initial parse | Medium — runs during page load | Navigation, cart, auth modals above the fold |
client:idle |
requestIdleCallback (+ setTimeout fallback) |
Low — background | Low — scheduled during idle periods | Analytics widgets, tooltips, secondary forms |
client:visible |
IntersectionObserver at configurable threshold |
On-demand — viewport-triggered | Low — only when scrolled into view | Carousels, data tables, charts below the fold |
client:media |
window.matchMedia() listener |
Conditional — fires when breakpoint matches | Low — conditional | Desktop-only dashboards, touch sliders |
client:only |
Immediate client render — no SSR | Framework-dependent | Varies | Components requiring window/document, third-party SDKs |
client:visible accepts an optional rootMargin configuration to pre-fetch the bundle before the component fully enters the viewport — useful when the hydration script itself is large:
---
// Pre-loads the React chunk when the island is 200px below the viewport edge
import HeavyChart from './HeavyChart.jsx';
---
Framework-Idiomatic Production Example
---
// src/pages/dashboard.astro
// Each component below uses a different directive — pick based on criticality.
import VanillaSearch from '../components/VanillaSearch.jsx';
import ReactChart from '../components/ReactChart.jsx';
import SvelteMetrics from '../components/SvelteMetrics.svelte';
import AuthWidget from '../components/AuthWidget.jsx';
---
Comparison: Client Directives vs Alternatives
When teams first encounter Astro’s directive system, they often compare it to explicit lazy-loading patterns in other frameworks. The table below maps each directive to its closest equivalent in React (App Router) and SvelteKit, and quantifies the difference in hydration control granularity. For a broader cross-framework view see SvelteKit Component Islands and Next.js App Router Streaming Patterns.
| Dimension | client:load |
client:idle |
client:visible |
React dynamic() + Suspense |
SvelteKit onMount |
|---|---|---|---|---|---|
| Bundle fetch timing | DOMContentLoaded | requestIdleCallback | IntersectionObserver | Immediate (eager) or on render | After mount |
| SSR fallback | Yes | Yes | Yes | Configurable (ssr: false) |
Yes (always) |
| Main-thread scheduling | Synchronous | Idle-queued | Observer-queued | Synchronous | Synchronous |
| Viewport awareness | No | No | Yes | No | No |
| Responsive (breakpoint) | No | No | No | No | Manual |
| Built-in bundle split | Yes (per island) | Yes (per island) | Yes (per island) | Yes (per route) | No (per page) |
The core advantage of Astro’s model is that the browser triggers hydration via native APIs rather than requiring a JavaScript scheduler to manage it. This keeps the main thread free during LCP and delegates scheduling cost to the browser’s own event loop.
Step-by-Step Integration Pattern
Step 1 — Enable on-demand rendering
Static output mode (output: 'static') disables streaming. Switch to 'server' or 'hybrid' and install a streaming-capable adapter:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
// 'server' = all routes SSR; 'hybrid' = opt individual routes into static
output: 'server',
// Swap the adapter for @astrojs/vercel, @astrojs/netlify, @astrojs/cloudflare, etc.
adapter: node({ mode: 'standalone' }),
});
Step 2 — Audit component criticality
Before assigning directives, classify every interactive component against three questions: Is it above the fold? Does the user interact with it within the first two seconds? Does it block a conversion action (checkout, login)?
- Critical (above fold, immediate interaction):
client:load - Secondary (visible but not interacted with immediately):
client:idle - Deferred (below fold or responsive-only):
client:visibleorclient:media - Browser-only (requires
window/document):client:only
Step 3 — Wrap components in .astro boundaries
Prop serialisation and hydration boundary control are cleanest when each island is a thin .astro wrapper, not a direct import in the page file. This isolates the serialisation contract and makes directive changes a one-line edit.
---
// src/components/islands/ChartIsland.astro
// Thin boundary wrapper — all prop coercion happens here before the island.
import ReactChart from '../ReactChart.jsx';
const { seriesId, title } = Astro.props;
// Coerce to primitives; Date / Map / Set objects will be stripped at the boundary.
const safeProps = JSON.stringify({ seriesId: String(seriesId), title: String(title) });
---
Step 4 — Coordinate props across the server–client boundary
Props are JSON-serialised before they cross the hydration boundary. Complex types are silently lost:
---
import MyComponent from './MyComponent.jsx';
// BAD: Date prototype is lost during JSON serialisation.
// Inside the React component, `timestamp` arrives as a string "2026-06-22T..."
const unsafeTimestamp = new Date();
// GOOD: Pass a primitive (number), reconstruct Date inside the component.
const safeTimestamp = Date.now();
---
Step 5 — Implement cross-island communication
Shared state across independent islands — a common pattern when mixing React and Svelte in the same layout — requires an explicit coordination layer. Direct DOM manipulation across hydration boundaries breaks isolation. Prefer a lightweight EventTarget-based bus or URL-driven state. For more complex patterns see event delegation in partially hydrated apps.
// src/utils/island-bus.js
// A singleton EventTarget shared across all islands on the page.
export const islandBus = new EventTarget();
// --- In a React island (publisher) ---
// Dispatch a typed custom event; payload stays primitive-safe.
islandBus.dispatchEvent(new CustomEvent('cart:update', { detail: { count: 3 } }));
// --- In a Svelte island (subscriber) ---
// Subscribe on mount; unsubscribe on destroy to prevent memory leaks.
import { onMount, onDestroy } from 'svelte';
let handler;
onMount(() => {
handler = (e) => updateCartBadge(e.detail.count);
islandBus.addEventListener('cart:update', handler);
});
onDestroy(() => islandBus.removeEventListener('cart:update', handler));
This approach differs from SvelteKit Component Islands where Svelte stores provide reactive shared state within a single framework. In Astro’s multi-framework context, explicit event dispatching is safer because it does not assume a shared runtime.
Measurement & Validation
Profiling with Chrome DevTools
- Open DevTools → Performance tab. Enable Screenshots and record a cold load with Network throttle → Fast 3G.
- In the Main Thread flame chart, locate
DOMContentLoaded. Anyclient:loadhydration task should appear within 100 ms of this marker. - Scroll to trigger
client:visibleislands. Their hydration tasks appear asTaskblocks in the Main Thread lane — verify they do not overlap with the LCP candidate’s paint event. - In the Network tab, filter by
/_astro/*.js. Confirm framework adapter files (client.react.js,client.svelte.js) are fetched lazily, not in the initial request waterfall.
Performance Marks
Insert performance.mark() calls to instrument hydration timing in CI:
// src/components/ReactChart.jsx
import { useEffect } from 'react';
export default function ReactChart({ 'data-series': series }) {
useEffect(() => {
// Emits a named mark visible in the DevTools Performance timeline and in PerformanceObserver.
performance.mark(`island:hydrated:ReactChart:${series}`);
}, []);
// ... component render
}
Hydration Budget Targets
| Metric | Target | Corrective action |
|---|---|---|
| Total JS payload (gzipped) | < 150 KB | Audit client:only usage; tree-shake unused framework adapters |
| TTI on 3G | < 2.5 s | Move non-critical client:load to client:idle |
| INP | < 200 ms | Avoid hydrating components that compete with LCP paint |
| LCP | < 2.5 s | Ensure client:load is reserved for components below the LCP element |
Failure Modes
1. Hydration mismatch from non-deterministic server props
Symptom: Console error Hydration failed because the initial UI does not match what was rendered on the server. The component falls back to full client rehydration, doubling the JS execution cost.
Cause: A prop value (timestamp, random ID, user-locale string) differs between the SSR pass and the first client render.
Fix: Pin all dynamic values to stable primitives before the boundary:
---
import Widget from './Widget.jsx';
// Stable: computed once during SSR, serialised to JSON, identical on the client.
const stableId = crypto.randomUUID(); // Node built-in; same value in SSR and client
const locale = Astro.request.headers.get('accept-language')?.split(',')[0] ?? 'en';
---
2. client:visible never fires
Symptom: An island never hydrates; the component remains in its static HTML state indefinitely. No errors are thrown.
Cause: IntersectionObserver fires only when an element is rendered with non-zero dimensions. If the island is inside a container with display:none, visibility:hidden, or height:0, the observer never triggers.
Fix:
---
import Panel from './Panel.jsx';
---
<div style="display:none">
</div>
3. client:only without an explicit framework name
Symptom: Build warnings about multiple framework adapters being bundled. Bundle size unexpectedly large.
Cause: Without the framework qualifier, Astro cannot infer which adapter to bundle and may include all installed framework runtimes.
Fix:
---
import AuthWidget from './AuthWidget.jsx';
---
Related
- Configuring
client:onlyvsclient:visiblein Astro — directive selection trade-offs, bundle splitting behaviour, and DOM readiness timing in detail. - Next.js App Router Streaming Patterns — how React Suspense boundaries and server component serialisation compare to Astro’s directive-based hydration.
- SvelteKit Component Islands — Svelte’s store-based state sharing and how it contrasts with Astro’s event-bus cross-island pattern.
- Understanding Partial Hydration — the foundational concept that Astro’s client directives implement.
- Cross-Boundary Prop Passing — serialisation rules and patterns for transferring data from server to client islands.