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.

Astro island hydration lifecycle A two-lane sequence diagram showing the server lane (SSR, streaming chunks, astro-island element) and the browser lane (IntersectionObserver, chunk fetch, hydration). Arrows connect events in time order from top to bottom. Server / SSR Browser 1. SSR: render to HTML 2. Inject <astro-island> wrapper 3. Stream HTML chunk to browser 4. Parse chunk; register IntersectionObserver 5. Island scrolls into view → fetch JS chunk 6. Hydrate component streaming HTML

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';
---











The Five Client Directives Placed on the Load Timeline A timeline from parse through idle to scroll and media-query change. Each of the five directives is placed at the moment it activates: client:load at parse, client:idle at the first idle callback, client:visible when the element intersects the viewport, client:media when its query matches, and client:only after the placeholder is discarded and the component renders from scratch in the browser. When each directive actually runs parse first idle scroll into view query matches client:load competes with the shell for main-thread time client:idle waits for a quiet frame may never fire on a busy page client:visible observer with rootMargin the default for most islands client:media breakpoint- gated widgets client:only no SSR HTML at all Every directive downloads the same bytes — they differ only in when those bytes are fetched and executed. Choosing a later trigger is a scheduling decision, not a payload optimisation. Both matter, and they are independent. ABOVE THE FOLD search, cart, primary nav → load or idle BELOW THE FOLD carousels, maps, comment threads → visible

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:visible or client: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

  1. Open DevTools → Performance tab. Enable Screenshots and record a cold load with Network throttle → Fast 3G.
  2. In the Main Thread flame chart, locate DOMContentLoaded. Any client:load hydration task should appear within 100 ms of this marker.
  3. Scroll to trigger client:visible islands. Their hydration tasks appear as Task blocks in the Main Thread lane — verify they do not overlap with the LCP candidate’s paint event.
  4. 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';
---





Directive Misuse — Symptom and Correction Three rows. Row one: a hero island marked client:only produces an empty box until the bundle arrives, hurting the largest contentful paint; the correction is to server-render it and defer only its interactivity. Row two: every island marked client:load produces one long blocking task; the correction is to stagger triggers by position. Row three: client:idle on a busy page never fires on low-end devices; the correction is a visible trigger with a root margin. MISUSE WHAT THE TRACE SHOWS CORRECTION client:only on the hero no server HTML for the component empty box until the chunk lands LCP moves 600–900 ms later render on the server, defer only the behaviour client:load everywhere eleven islands, one trigger a single 400 ms blocking task input queued behind hydration stagger by position: load, idle, visible client:idle on a busy page third-party scripts hog the thread island never activates on low-end works on the developer laptop use client:visible with a generous rootMargin

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.

← Back to Framework-Specific Islands & Streaming SSR