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











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.

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





← Back to Framework-Specific Islands & Streaming SSR