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.
Choosing a Directive Without Guessing
Directive choice becomes mechanical once you separate the two questions it answers. The first is when the component must be able to respond — the moment a visitor could plausibly interact with it. The second is what it costs to be ready — the transferred bytes plus the execution time of its mount. Only when the answer to the first is “immediately, on the first screen” does an eager trigger earn its cost.
Work through a route in three passes. In the first pass, load the page on a throttled connection and note every element a visitor can reach without scrolling; those are candidates for client:load and nothing else is. In the second pass, note the elements that appear after a short scroll — these get client:visible, and a rootMargin of roughly one viewport height so activation starts before the element is on screen rather than as it arrives. Everything left over is deferred work: chat widgets, feedback panels, analytics overlays. These take client:idle if they are cheap, or a visible trigger if they are not, because an idle callback on a page loaded with third-party scripts may not fire on a low-end device for several seconds — or at all.
The one case that deserves suspicion every time is client:only. It is the correct tool for a component that genuinely cannot render on the server — a canvas visualisation that measures the DOM, a widget wrapping a browser-only library — and the wrong tool for anything else, because it removes the server HTML that gives the page its shape. When a component fails to render on the server, the underlying reason is usually a top-level reference to window or document inside a dependency; moving that access into an effect that runs after mount is usually a smaller change than paying for a blank box on every load. Keep a note in the component of why it is browser-only, or the next person will assume the constraint is real and design around it.
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';
---
Frequently Asked Questions
When should I use client:only instead of client:load?
Use client:only for components that depend on browser-only APIs (window, document, localStorage) or third-party SDKs that cannot run in Node.js. client:load hydrates after SSR; client:only skips SSR entirely and renders exclusively in the browser.
Why does client:visible hydration not trigger on mobile?
If IntersectionObserver is not supported or the component is in a display:none container, the observer never fires. Verify observer support, check CSS visibility, and add a fallback via the rootMargin option.
Can I pass a Date object as a prop to an Astro island?
No. Props are serialised to JSON during SSR, so Date instances become strings and lose their prototype methods. Pass Date.now() as a number or a pre-formatted ISO string, then reconstruct the Date inside the component.
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.
-
Using Astro Server Islands for Personalized Content — defer per-user fragments with
server:deferwhile keeping the rest of the page cached.