Siddhant Deval
Siddhant Deval
frontend5 min read

Islands Architecture & Cross-Framework Hydration

Islands Architecture reduces Time to Interactive by shipping zero JavaScript for static content — but the boundary contract between static sea and interactive island is a serialization problem, not a rendering problem, and crossing it incorrectly produces invisible data loss.

Islands Architecture & Cross-Framework Hydration

A Next.js app with one interactive dropdown in the footer hydrates the entire React tree. The navigation, the hero section, the marketing copy, the testimonials, the team grid — every component is downloaded as JavaScript, parsed by V8, hydrated by React, and held in memory for the lifetime of the page. If none of these components ever need to update their state or respond to user events, that is pure waste: JavaScript executed to make static content interactive, when the content was never going to be interactive.

Islands Architecture makes a different decision. Static content is HTML — full stop. No JavaScript is shipped for it. Interactive components — search, shopping cart, video player, form — are isolated "islands" in a static HTML "sea," each carrying only its own JavaScript bundle, each hydrating independently, each invisible to the hydration cost of the others. The result is a TTI that scales with the count of interactive components, not the count of all components.


1. The Broken Pattern: Hydrating the Entire Tree

TYPESCRIPT
// ❌ Standard Next.js/React — the full tree hydrates regardless of interactivity
// Every component in this tree downloads and executes JavaScript

export default function MarketingPage() {
  return (
    <>
      <SiteHeader />          {/* Static — never changes. Still hydrated. */}
      <HeroSection />         {/* Static image + headline. Still hydrated. */}
      <FeatureGrid />         {/* 12 static feature cards. All hydrated. */}
      <Testimonials />        {/* 8 static testimonial blocks. All hydrated. */}
      <PricingTable />        {/* Static comparison table. Still hydrated. */}
      <NewsletterForm />      {/* ← The ONE component that needs JS */}
      <Footer />              {/* Static links. Still hydrated. */}
    </>
  )
}
// Total JS parsed for hydration: ~180KB
// JS that actually does anything interactive: ~8KB (the form)
// Wasted hydration: ~172KB

The ratio worsens on content-heavy sites: a documentation page, a news article, a product landing page. The more static content there is, the worse the relative cost of hydrating the entire React tree.


2. Islands Architecture Defined

Islands Architecture separates a page into two zones:

The static sea: Rendered to HTML at build time (or request time). Sent to the browser as plain HTML. Zero JavaScript shipped for these elements. No parse cost, no execute cost, no hydration cost.

Interactive islands: Components that need JavaScript for state, events, or browser APIs. Each island is a self-contained unit with its own JavaScript bundle, its own hydration point, and its own scheduler directive that controls when it hydrates.

Rendered page:

┌─────────────────────────────────────────────┐
│         SiteHeader (HTML — 0 JS)            │  ← sea
│         HeroSection (HTML — 0 JS)           │  ← sea
│  ╔═══════════════════════════════╗           │
│  ║  SearchBar Island (React)     ║  ← island │
│  ║  client:load · 12KB           ║           │
│  ╚═══════════════════════════════╝           │
│         FeatureGrid (HTML — 0 JS)           │  ← sea
│  ╔═════════════╗  ╔═════════════╗            │
│  ║ VideoPlayer ║  ║ CartWidget  ║  ← islands │
│  ║ client:idle ║  ║ client:load ║            │
│  ╚═════════════╝  ╚═════════════╝            │
│         Footer (HTML — 0 JS)                │  ← sea
└─────────────────────────────────────────────┘

3. The Serialization Contract

The boundary between the static sea and an interactive island is a serialization boundary. Island props must be serialized into the HTML output so the island's JavaScript can read them at hydration time.

Astro serializes island props as JSON in an inline <script type="application/json"> element:

HTML
<!-- What Astro generates in the HTML for a React island -->
<astro-island
  uid="abc123"
  component-export="SearchBar"
  component-url="/_astro/SearchBar.abc123.js"
  renderer-url="/_astro/client.react.js"
>
  <script type="application/json">
    {"placeholder":"Search docs...","initialQuery":"","maxResults":10}
  </script>
  <!-- Server-rendered HTML fallback while JS loads -->
  <input type="search" placeholder="Search docs..." />
</astro-island>

What can cross the serialization boundary (JSON-serializable):

TYPESCRIPT
// ✅ Valid island props — all JSON-serializable
interface SearchBarProps {
  placeholder: string         // string
  maxResults: number          // number
  categories: string[]        // array of primitives
  config: { fuzzy: boolean }  // plain object
  initialQuery?: string       // optional primitive
}

What cannot cross the serialization boundary:

TYPESCRIPT
// ❌ Invalid — cannot be JSON-serialized, cannot cross the island boundary
interface BrokenProps {
  onSearch: (q: string) => void  // Function — NOT serializable
  client: PrismaClient           // Class instance — NOT serializable
  ref: React.RefObject<Element>  // React ref — NOT serializable
  children: React.ReactNode      // JSX/React elements — NOT serializable
}
Performance / Safety Warning

Passing a function as an island prop compiles silently in some Astro versions but produces undefined at hydration time. The island receives no callback and the user sees no error — the interaction simply does not work. Always validate that all island props are JSON-serializable primitives or plain objects.


4. Astro Islands: Hydration Directives

Astro provides five hydration directives that control when an island's JavaScript loads and executes:

ASTRO
---
import SearchBar from './SearchBar.tsx'
import VideoPlayer from './VideoPlayer.tsx'
import NewsletterForm from './NewsletterForm.tsx'
import StockTicker from './StockTicker.tsx'
import MobileMenu from './MobileMenu.tsx'
---

<!-- client:load — hydrate immediately on page load. Use for above-the-fold interactive elements. -->
<SearchBar client:load placeholder="Search..." />

<!-- client:idle — hydrate when the browser's main thread is idle (requestIdleCallback).
     Use for non-critical interactive elements that don't need to be ready immediately. -->
<StockTicker client:idle symbols={["AAPL", "GOOGL"]} />

<!-- client:visible — hydrate when the island enters the viewport (IntersectionObserver).
     Use for below-the-fold islands — they don't hydrate until the user scrolls to them. -->
<VideoPlayer client:visible src="/demo.mp4" />

<!-- client:media — hydrate when a CSS media query matches.
     Use for islands that only make sense at specific viewport sizes. -->
<MobileMenu client:media="(max-width: 768px)" />

<!-- client:only="react" — skip SSR entirely, hydrate only on the client.
     Use for islands that depend on browser APIs unavailable during SSR. -->
<BrowserOnlyComponent client:only="react" />
Directive Trigger Use Case
client:load Immediately on page load Above-the-fold interactive; navigation; search
client:idle Browser main thread idle Non-critical widgets; social embeds; analytics
client:visible Island enters viewport Video players; complex charts; comment sections
client:media CSS media query matches Mobile-only menus; responsive interactive panels
client:only Client-side only Browser API-dependent islands (maps, WebGL)

4.1 Multi-Framework on the Same Page

Astro islands are framework-agnostic. A single page can have a React island, a Vue island, and a Svelte island — each using its own framework runtime:

ASTRO
---
import ReactSearch from './Search.tsx'    // React component
import VueChart from './Chart.vue'        // Vue component
import SvelteModal from './Modal.svelte'  // Svelte component
---

<ReactSearch client:load />
<VueChart client:visible data={chartData} />
<SvelteModal client:idle />

React runtime deduplication: If multiple islands on the same page use React, Astro loads the React runtime (React + ReactDOM) once and shares it across all React islands. The React runtime is not duplicated per island. The same applies for Vue, Svelte, and other frameworks — each framework runtime is loaded at most once per page.


5. Qwik's Resumability — Not Islands

Qwik is often grouped with Islands Architecture but operates on a fundamentally different model: resumability.

Property Islands (Astro) Resumability (Qwik)
Approach Separate static sea from isolated interactive islands Serialize entire app state + event handlers into HTML
JavaScript on load Zero for sea; island JS loads lazily per directive Near-zero — only framework loader, ~1KB
Hydration Each island replays its component initialization No hydration replay — resumes from serialized closure
State shared across components Requires signal store or event bus Implicit — all state is in the serialized closure
HTML payload size Minimal (HTML + island prop JSON) Larger — full closure serialization into HTML
Framework portability Any framework per island Qwik-specific — not portable to React/Vue apps
HTML
<!-- Qwik serializes event handlers into the HTML as qrl references -->
<button on:click="./chunk-abc.js#handleClick_component_A[0]">
  Click me
</button>
<!-- No JS bundle executed on load — Qwik lazy-loads the handler only when the button is clicked -->

The trade-off is explicit: Qwik achieves near-zero JS on initial load for any application complexity, but requires adopting the Qwik framework entirely. Islands Architecture achieves zero JS for static content, but interactive islands still load and initialize their own framework runtime.


6. Cross-Island Communication

The isolation contract of Islands Architecture means islands cannot share React state, context, or refs. Two islands communicating requires an explicit coordination mechanism.

6.1 CustomEvent — Works but Breaks Isolation

TYPESCRIPT
// Island A — dispatches a custom event
function SearchBar() {
  const handleSearch = (query: string) => {
    document.dispatchEvent(new CustomEvent('search:query', { detail: { query } }))
  }
  return <input onChange={e => handleSearch(e.target.value)} />
}

// Island B — listens for the custom event
function ResultsPanel() {
  const [query, setQuery] = useState('')
  useEffect(() => {
    const handler = (e: CustomEvent) => setQuery(e.detail.query)
    document.addEventListener('search:query', handler)
    return () => document.removeEventListener('search:query', handler)
  }, [])
  return <ResultsList query={query} />
}

This works, but the document coupling means any island anywhere on the page can dispatch or intercept 'search:query' events. There is no type safety, no ownership, and no way to scope the event to a specific pair of islands.

6.2 Nanostores — The Correct Pattern

TYPESCRIPT
// shared-store.ts — a tiny shared signal store (no framework dependency)
import { atom } from 'nanostores'

export const searchQuery = atom<string>('')

// Island A — writes to the store
import { searchQuery } from './shared-store'

function SearchBar() {
  return <input onChange={e => searchQuery.set(e.target.value)} />
}

// Island B — reads from the store; re-renders when the value changes
import { useStore } from '@nanostores/react'
import { searchQuery } from './shared-store'

function ResultsPanel() {
  const query = useStore(searchQuery)
  return <ResultsList query={query} />
}

Nanostores is 265 bytes. It provides typed, scoped, reactive state that works across any framework — the @nanostores/react, @nanostores/vue, and @nanostores/svelte adapters bind the store to each framework's reactivity system.


7. When Islands Architecture Is the Wrong Choice

Islands Architecture is optimal for static-content-dominant pages with a small number of interactive components. It is the wrong choice when:

Interactive surface area > ~50% of the page
OR
Islands need to share complex state with deeply nested bidirectional updates
OR
You need React Router/Next.js-style client-side navigation between routes

→ In these cases, the island boundary overhead and cross-island coordination cost
  exceeds the JavaScript elimination savings. Use Next.js with RSC + PPR instead.

Summary

Concept Rule
Static sea Plain HTML — zero JavaScript shipped. No parse, execute, or hydration cost.
Serialization boundary Island props must be JSON-serializable. Functions, class instances, React elements, and refs cannot cross the boundary.
client:visible The highest-impact directive for below-the-fold islands — hydrates only when the island enters the viewport. Default to this for non-critical islands.
Runtime deduplication Astro loads each framework runtime once per page, not once per island. Multiple React islands share one React bundle.
Cross-island state Prefer a shared signal store (Nanostores) over CustomEvent on document. The store provides type safety, ownership, and framework-agnostic reactivity.
Qwik contrast Resumability = serialize entire app closure into HTML, resume without replay. Islands = zero JS for static, isolated JS per island. Different trade-offs, not equivalent.

What's Next

In Part 6, we move from rendering architecture to measurement — LCP (Largest Contentful Paint), the metric that determines how fast your most important content reaches users. The fix is not image compression — it is resource priority scheduling. Part 6: LCP →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Islands Architecture#Astro#Qwik#Partial Hydration#Performance#TTI
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.