Web Engineering ·

Islands Architecture in Practice: Partial Hydration, Selective Rendering, and Performance Patterns in Astro

A practical deep dive into islands architecture and partial hydration in Astro. Covers how client:* directives work, when to hydrate and when not to, measuring the performance impact, patterns for interactive islands in mostly-static pages, and how this compares to React Server Components.

Islands Architecture in Practice: Partial Hydration, Selective Rendering, and Performance Patterns in Astro

Most performance problems in content-heavy websites come from the same structural decision: you used a JavaScript framework for a page that is 95% static HTML, and now every visitor downloads, parses, and executes a multi-hundred-kilobyte bundle before they can interact with a search box or a newsletter form.

Islands architecture is a direct response to that problem. Render everything as static HTML by default. Hydrate only the components that require interactivity. Astro is the most mature implementation of this pattern, with enough control over hydration timing to make meaningful performance improvements without rewriting your application.

This article covers how islands architecture works mechanically, how Astro’s client:* directives implement it, how to measure the impact, patterns for interactive islands in mostly-static pages, and how the approach compares to React Server Components.

What Islands Architecture Actually Means

The term was coined by Etsy engineer Katie Sylor-Miller in 2019 and later expanded by Jason Miller. The mental model is a static HTML “sea” with discrete interactive “islands” embedded in it. Each island hydrates independently; the rest of the page requires no JavaScript.

This differs from SSR in a critical way. In a Next.js pages-router SSR app, the server renders full HTML, but then the browser hydrates the entire component tree in one pass. That hydration ties up the main thread and ships every component’s JavaScript to the browser regardless of whether it is interactive.

Islands architecture breaks the “hydrate everything” assumption. The server renders full HTML. JavaScript only loads for components that explicitly opt in, and each one hydrates independently. A blog post body, navigation bar, footer, and sidebar can all render as zero-JavaScript HTML, while a comment form and a dark mode toggle each load only their own JavaScript, isolated from each other.

How Astro Implements Islands

Astro’s component model defaults every component to server-only rendering. An .astro file renders at build time (or request time with SSR) and outputs plain HTML. No JavaScript is emitted for the component unless you explicitly opt in.

For framework components (React, Svelte, Vue, Solid, Preact), you use client:* directives to control if and when the component hydrates in the browser.

---
// src/pages/blog/[slug].astro
import { getEntry } from 'astro:content';
import Navigation from '../components/Navigation.astro';
import ArticleBody from '../components/ArticleBody.astro';
import CommentForm from '../components/CommentForm.tsx';
import DarkModeToggle from '../components/DarkModeToggle.tsx';
import NewsletterForm from '../components/NewsletterForm.tsx';

const { slug } = Astro.params;
const entry = await getEntry('blog', slug);
---

<html>
  <body>
    <Navigation /> <!-- Pure HTML, zero JS -->
    <ArticleBody content={entry.body} /> <!-- Pure HTML, zero JS -->

    <!-- These three are interactive islands -->
    <DarkModeToggle client:load />
    <NewsletterForm client:visible />
    <CommentForm client:idle />
  </body>
</html>

The .astro components render HTML only. The .tsx components ship JavaScript only because they have a client:* directive.

Each directive controls a different hydration strategy:

client:load hydrates the component immediately when the page loads. Use this for components that must be interactive before the user can accomplish anything meaningful: a search bar on a search page, the primary navigation menu if it has dropdowns that mobile users will immediately tap.

client:idle hydrates after the browser finishes its initial load work and fires requestIdleCallback. The component’s JavaScript is downloaded immediately but hydration waits for idle time. Use this for components that are important but not in the critical path: a chat widget, a recommendations carousel, a “recently viewed” section.

client:visible hydrates when the component enters the viewport, using an IntersectionObserver. The JavaScript is not even downloaded until the component scrolls into view. Use this for anything below the fold: comments sections, embedded demos, related content.

client:media hydrates when a CSS media query matches. Useful for components that only exist in a specific viewport state (a mobile-only bottom navigation, for example).

client:only skips server rendering entirely and renders the component only in the browser. Use this sparingly: it is the escape hatch for components that genuinely cannot run on the server (things that call browser APIs in module scope, third-party embeds that assume a browser environment).

When to Hydrate and When Not To

The default answer is: do not hydrate unless there is a specific user interaction that requires it.

Before adding a client:* directive, ask:

  • Does this component handle user events (onClick, onChange, keyboard input)?
  • Does it maintain local state that changes over time?
  • Does it use browser-only APIs (localStorage, IntersectionObserver, window.matchMedia)?
  • Does it need to subscribe to real-time data after the initial page load?

If the answer to all four is no, the component should render as static HTML. A testimonials carousel that autorotates can be built with CSS animations and zero JavaScript. A “current year” display needs no JavaScript. Navigation links need no JavaScript. An article body, a pricing table, a features grid: all static HTML.

When the answer to any question is yes, you need hydration. The directive hierarchy for content sites: most components fit client:idle or client:visible. client:load is reserved for a small number of elements that must be interactive before the user can accomplish their primary task. client:media is for components scoped to a specific viewport (mobile-only navigation). Default to the laziest directive that still gives users a responsive experience.

Measuring the Performance Impact

The simplest way to see what islands architecture buys you is to compare total JavaScript shipped per page versus what a comparable Next.js or CRA app would send.

Astro’s build output includes a size breakdown. Each island generates its own JavaScript chunk. Shared dependencies between islands are automatically split into shared chunks.

To measure in the browser, use the Network tab filtered to JavaScript resources. On a well-structured Astro site, you should see:

  1. A small Astro runtime chunk (usually 5-15 KB gzipped for the hydration coordinator)
  2. Individual island chunks, each loading when their directive fires

Compare this to an equivalent Next.js app’s _app.js and page bundle: the difference is typically substantial. A content site with three interactive components should not be shipping 200 KB of JavaScript. With Astro and the right directive choices, it might ship 30-40 KB total.

The Core Web Vitals to focus on with islands architecture:

// Measure island hydration timing in production
if (typeof window !== 'undefined' && 'PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-input') {
        // First Input Delay: lower with islands because fewer scripts block the main thread
        console.log('FID:', entry.processingStart - entry.startTime);
      }
    }
  });
  observer.observe({ type: 'first-input', buffered: true });
}

The three metrics that islands architecture directly affects:

  • First Contentful Paint (FCP): Improves significantly because the browser receives full HTML immediately, not a JavaScript-rendered shell.
  • Total Blocking Time (TBT): Lower because less JavaScript parses and executes on the main thread before the page is interactive.
  • Interaction to Next Paint (INP): Depends on hydration strategy. client:load islands contribute to early blocking; client:idle and client:visible defer that cost.

Patterns for Interactive Islands in Mostly-Static Pages

Shared State Between Islands

Islands are isolated by design. If two islands need to share state, they need a mechanism that exists outside the React/Svelte/Vue component tree. Astro’s recommended approach is nano-stores or any observable store that components can subscribe to.

// src/stores/cart.ts
import { atom, computed } from 'nanostores';

export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const cartItems = atom<CartItem[]>([]);

export const cartCount = computed(cartItems, (items) =>
  items.reduce((sum, item) => sum + item.quantity, 0)
);

export const cartTotal = computed(cartItems, (items) =>
  items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export function addToCart(item: CartItem): void {
  const current = cartItems.get();
  const existing = current.find((i) => i.id === item.id);
  if (existing) {
    cartItems.set(
      current.map((i) =>
        i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
      )
    );
  } else {
    cartItems.set([...current, item]);
  }
}
// src/components/AddToCartButton.tsx
import { useStore } from '@nanostores/react';
import { cartItems, addToCart } from '../stores/cart';

interface Props {
  productId: string;
  productName: string;
  price: number;
}

export function AddToCartButton({ productId, productName, price }: Props) {
  const items = useStore(cartItems);
  const inCart = items.some((i) => i.id === productId);

  return (
    <button
      onClick={() => addToCart({ id: productId, name: productName, price, quantity: 1 })}
      disabled={inCart}
    >
      {inCart ? 'Added' : 'Add to Cart'}
    </button>
  );
}
// src/components/CartWidget.tsx
import { useStore } from '@nanostores/react';
import { cartCount, cartTotal } from '../stores/cart';

export function CartWidget() {
  const count = useStore(cartCount);
  const total = useStore(cartTotal);

  return (
    <div className="cart-widget">
      <span>{count} items</span>
      <span>${total.toFixed(2)}</span>
    </div>
  );
}
---
// src/pages/products/[id].astro
import { AddToCartButton } from '../../components/AddToCartButton.tsx';
import { CartWidget } from '../../components/CartWidget.tsx';
---

<header>
  <CartWidget client:load />
</header>

<main>
  <!-- Static product content, zero JS -->
  <h1>{product.name}</h1>
  <p>{product.description}</p>

  <!-- Island: interactive, hydrates immediately -->
  <AddToCartButton
    client:load
    productId={product.id}
    productName={product.name}
    price={product.price}
  />
</main>

The nanostores library is small (under 1 KB) and framework-agnostic, which matters for islands: you might have a React component and a Svelte component on the same page both reading from the same store.

Progressive Enhancement Pattern

Islands work well with progressive enhancement: render a functional server-side HTML version, then hydrate to add richer interactivity.

---
// The server renders a working form that submits traditionally
// The island enhances it with client-side validation and AJAX submission
---

<form id="contact-form" action="/api/contact" method="POST">
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

<!-- This island enhances the form if JavaScript is available -->
<ContactFormEnhancer client:idle targetFormId="contact-form" />

The form works without JavaScript via a standard POST. The island layers on top to add instant validation feedback and avoid a page reload. Users on slow connections or with JavaScript disabled get a working experience; users with a good connection get the enhanced version after the browser is idle.

Hydration for Third-Party Embeds

client:only is the right directive for third-party code that assumes browser APIs at import time.

---
// Intercom, Stripe.js, analytics scripts: none of these can SSR.
// client:only skips server rendering entirely.
---

<IntercomWidget client:only="react" userId={user.id} />
<StripePaymentElement client:only="react" clientSecret={paymentIntent.clientSecret} />

The tradeoff: these components produce no HTML from the server. The user sees nothing for these areas until JavaScript loads. For a payment form that only appears after user interaction, this is fine. For above-fold content, prefer a static placeholder that gets replaced.

Comparison with React Server Components

Islands architecture and React Server Components solve an overlapping problem from different starting points. Understanding the distinction helps you choose the right tool.

DimensionAstro IslandsReact Server Components
Default JavaScriptZero for static componentsZero for server components
Hydration granularityPer component, explicit opt-inPer component, automatic for "use client" subtrees
Framework couplingFramework-agnostic (React, Svelte, Vue, Solid)React-native
Shared state between islandsExternal store (nanostores, Zustand)React Context, RSC payload
StreamingSupported in SSR modeNative, with Suspense boundaries
Component communicationEvent-based or external storeProps, context, Server Actions
Build-time staticFirst-class (Astro’s default)Possible but framework adds overhead
Complexity ceilingLower: harder to build complex app UIsHigher: React ecosystem available throughout
Best fitContent sites, marketing pages, documentationApplications with complex interactive UIs

The critical distinction: in RSC, Client Components hydrate as a full React subtree. Marking a component "use client" hydrates it and everything below it. You can push the boundary deeper, but you are working within a single React tree.

In Astro, islands are fully isolated. They do not share a React tree and cannot read each other’s state without going through an external store or the DOM. This isolation produces less JavaScript for simple cases and is harder to work with for complex application UIs.

RSC streaming and Suspense is more granular: you can stream individual page sections based on server-side data fetch completion. Astro SSR supports some response streaming, but it is not as fine-grained as React’s Suspense boundary model.

For content sites (documentation, marketing, blogs): Astro is the clearer choice. Near-zero JavaScript by default, excellent build-time performance, and enough interactivity for the common patterns. For applications with complex interactive UIs: RSC in Next.js trades a larger JavaScript baseline for a much higher complexity ceiling.

Production Tradeoffs

Build times grow with content. Astro’s default mode builds all pages at build time. A documentation site with 2,000 pages can have build times in the minutes. Incremental builds help, but this is a real operational concern. SSR mode moves build cost to request time, which shifts the problem differently rather than eliminating it.

Island bundle size accumulates. Each island loads its own JavaScript chunk. Astro deduplicates the framework runtime automatically when multiple islands use the same framework, but mixing React and Svelte on the same page means two separate framework runtimes load. Keep your island framework choices consistent per page.

No shared React context across islands. This is not a bug, it is the architecture. Patterns you might take for granted in a React app (a global ThemeContext, a shared auth context) need to be reimplemented as external stores. The nanostores pattern works, but it is a different mental model that takes adjustment.

client:visible has scroll-to-anchor gotchas. If a component is offscreen at load time but immediately scrolls into view via an anchor link, the IntersectionObserver fires immediately. Components with expensive initialization costs can still cause jank in this case. Test anchor-link navigations explicitly.

View Transitions and island state. Astro’s View Transitions API enables client-side navigation with animated transitions. When navigating between pages, islands reinitialize. State in a nanostores atom survives because it lives outside the component tree, but local component state (useState, Svelte’s $state) resets on navigation. Design your state architecture with this in mind from the start.

Closing

Islands architecture is not a universal answer to web performance. It is the right answer for a specific category of site: pages where most content is static and interactivity is sparse and well-defined. For those sites, the default-zero-JavaScript model that Astro implements produces measurably better performance than alternatives, with less complexity for the common cases.

The client:* directive system gives you enough control to make real optimization decisions rather than accepting framework defaults. Use client:load for what must be immediately interactive, client:idle for what matters but is not urgent, and client:visible for anything below the fold. Default to no hydration, and justify each island you add.

Where islands architecture reaches its limits, React Server Components pick up the thread. The two approaches share the same underlying insight: ship less JavaScript by making server rendering the default. The difference is in how much application complexity each can handle while maintaining that property.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.