Islands Architecture

Production engineering guide for islands architecture covering patterns, implementation strategies, and operational best practices.

Islands Architecture is a frontend rendering strategy where a page is composed of a static or server-rendered shell, with interactive, self-contained components—called *islands*—that are independently rendered, hydrated, and reloaded in the browser. It matters when you want fast initial load and rich interactivity without the overhead of a monolithic client-side app. It shines in content-heavy sites with dynamic, isolated UI regions—like a blog with comment threads, a product page with a live inventory widget, or a dashboard with multiple real-time charts.

Rendering the Islands

A page starts as a static HTML document, generated on the server or at build time. Each island is a self-contained UI fragment—typically a component with its own JavaScript, state, and event handlers—that is rendered independently. The server sends the full page, and each island is marked with a data-island attribute and a data-src or data-module that points to its JavaScript bundle. The browser then fetches and executes the script for each island, hydrating it into an interactive component.

The core mechanism is a client-side loader that discovers all islands via a data-island attribute and orchestrates their hydration. The loader is a small script (typically 1–3 KB) injected into the HTML, responsible for:

<!-- Example of an island in the HTML -->
<article data-island="comment-thread" data-src="/islands/comment-thread.js">
  <h2>Comments</h2>
  <div class="comment">
    <p>Great post! — Alice</p>
  </div>
  <button>Reply</button>
  <input type="text" placeholder="Write a reply…" />
</article>

The data-src attribute points to a JavaScript module that exports a hydrate function. The module is served as a standard ES module, using import and export.

// /islands/comment-thread.js
export function hydrate(element) {
  const form = element.querySelector('form');
  const input = form.querySelector('input');
  const button = form.querySelector('button');

  button.addEventListener('click', () => {
    const text = input.value;
    if (!text.trim()) return;

    const newComment = document.createElement('div');
    newComment.className = 'comment';
    newComment.innerHTML = `<p>${text}</p>`;
    element.insertBefore(newComment, form);

    input.value = '';
  });

  return {
    destroy() {
      button.removeEventListener('click', () => {});
    },
  };
}

When the client-side loader runs, it fetches comment-thread.js, calls hydrate(element), and the comment thread becomes interactive. The hydration is idempotent: multiple calls to hydrate on the same element do not create duplicate listeners.

Hydration Lifecycle

Hydration is the process of transforming a static DOM node into a live, interactive component. The hydrate function receives the DOM node and returns a destroy function that tears down the component's state and event listeners.

The client-side loader handles multiple hydration phases:

  1. Preload: The loader scans the document and preloads all island scripts.
  2. Fetch: It fetches each script in parallel.
  3. Execute: Each script is executed in order, with dependencies resolved.
  4. Hydrate: hydrate is called on each island, and the returned destroy function is stored.
  5. Cleanup: On navigation or rehydration, the loader calls destroy on all active islands.

If the page is navigated to via client-side routing (e.g., history.pushState), the loader may rehydrate the islands on the new page without a full reload. It checks for data-island attributes and rehydrates only the islands that were already in the DOM.

Island Communication and State Sharing

Islands can communicate with each other using a shared event bus, typically a CustomEvent dispatcher. A common pattern is to emit events from one island and listen on others.

// Inside a product-card island
export function hydrate(element) {
  const button = element.querySelector('button');
  const price = element.querySelector('.price');

  button.addEventListener('click', () => {
    const event = new CustomEvent('product-selected', {
      detail: { id: element.dataset.id, price: price.textContent },
    });
    window.dispatchEvent(event);
  });

  return {
    destroy() {
      button.removeEventListener('click', () => {});
    },
  };
}

// In a cart-island script
window.addEventListener('product-selected', (e) => {
  const { id, price } = e.detail;
  const cart = document.querySelector('#cart');
  const item = document.createElement('li');
  item.textContent = `Added ${id} at $${price}`;
  cart.appendChild(item);
});

For more complex state sharing, islands can use a global store, such as a SharedWorker or BroadcastChannel.

// Using BroadcastChannel for real-time state
const channel = new BroadcastChannel('island-store');

export function hydrate(element) {
  const input = element.querySelector('input');
  const button = element.querySelector('button');

  input.addEventListener('input', (e) => {
    channel.postMessage({ type: 'search', query: e.target.value });
  });

  channel.addEventListener('message', (e) => {
    if (e.data.type === 'results') {
      const list = element.querySelector('ul');
      list.innerHTML = e.data.results.map(r => `<li>${r.title}</li>`).join('');
    }
  });

  return {
    destroy() {
      input.removeEventListener('input', () => {});
      channel.close();
    },
  };
}

This communication model is asynchronous and decoupled: islands need not be loaded in a specific order, and a new island can appear at any time and join the event loop.

Server-Client Hydration Coordination

In a server-rendered setup, islands are pre-rendered on the server. The server generates the HTML with data-island attributes and embeds the initial state of each island.

The client-side loader uses this pre-rendered state to initialize the component’s state, avoiding the need for a round-trip to the server.

// Server-side rendering of a comment thread
<article data-island="comment-thread" data-src="/islands/comment-thread.js" data-state="{'comments': [{'id': 1, 'text': 'Nice!'}]}">
  <h2>Comments</h2>
  <div class="comment">
    <p>Nice!</p>
  </div>
  <button>Reply</button>
  <input type="text" placeholder="Write a reply…" />
</article>

The client-side loader reads data-state and passes it to hydrate, allowing the component to begin in a known state.

// In the island script
export function hydrate(element, initialData) {
  const { comments } = initialData;

  const list = element.querySelector('ul');
  comments.forEach(comment => {
    const item = document.createElement('li');
    item.textContent = comment.text;
    list.appendChild(item);
  });

  return {
    destroy() {
      // tear down listeners
    },
  };
}

The data-state attribute is a JSON object embedded in the HTML. The client can also send data-island-config for configuration.

<article
  data-island="product-grid"
  data-src="/islands/product-grid.js"
  data-state="{'products': [{ 'id': 1, 'name': 'Widget' }]}"
  data-config="{'showFilters': true, 'sort': 'price'}"
>
  <!-- content -->
</article>

The data-config attribute is used for configuration parameters that the island script can consume.

Error Handling and Failure Modes

The most common failure mode is island script not found. The client loader fetches data-src but receives a 404. The response contains a text/plain error message:

HTTP/1.1 404 Not Found
Content-Type: text/plain

404 Not Found: /islands/comment-thread.js

The loader logs this error and continues with the rest of the page. It does not fail the entire hydration process.

When the hydrate function throws an error, the loader captures it and logs it to the console. The error includes the island’s data-island attribute and the stack trace.

// Example error output
Error: Failed to hydrate comment-thread island
    at hydrate (comment-thread.js:8)
    at IslandLoader.hydrate (island-loader.js:42)
    at IslandLoader.load (island-loader.js:35)

Caused by: TypeError: Cannot read property 'addEventListener' of null
    at hydrate (comment-thread.js:11)

The loader can also retry failed island loads. If a script fails to load, it retries after 1 second and logs a warning.

// Retry logic in island loader
async function loadIsland(element) {
  const src = element.dataset.src;
  let attempts = 0;
  const maxAttempts = 3;

  while (attempts < maxAttempts) {
    try {
      const response = await fetch(src);
      if (!response.ok) throw new Error(`Failed to load ${src}`);

      const script = await response.text();
      const module = new Function(script)();
      const hydrate = module.hydrate;

      hydrate(element);
      return;
    } catch (err) {
      console.warn(`Failed to load island ${src} (attempt ${attempts + 1})`, err);
      await sleep(1000);
      attempts++;
    }
  }

  console.error(`Failed to load island ${src} after ${maxAttempts} attempts`);
}

A subtle but critical issue is duplicate hydration. If the same island element is hydrated twice—either because the same script is loaded twice or because the same component appears in two places—the second hydration overwrites the first.

The loader can detect this using a data-hydrated attribute:

export function hydrate(element) {
  if (element.hasAttribute('data-hydrated')) {
    console.warn('Island already hydrated:', element.dataset.island);
    return;
  }

  element.setAttribute('data-hydrated', 'true');

  // ... rest of hydration
}

This prevents double event listeners, duplicated state, and performance degradation.

Islands in Dynamic Content

When content is injected dynamically (e.g., via fetch or IntersectionObserver), the loader must scan the new DOM nodes for islands.

The loader can be triggered manually:

// After loading new content
const newContent = await fetch('/api/posts/2');
const html = await newContent.text();

const container = document.getElementById('post-list');
container.insertAdjacentHTML('beforeend', html);

// Scan new content for islands
islandLoader.scan(container);

The scan method walks through the given node and registers all data-island elements. It then fetches and hydrates them.

The loader can also be configured to scan on scroll:

// Auto-scan on scroll
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      islandLoader.scan(entry.target);
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.1 });

// Observe all containers
document.querySelectorAll('.post-container').forEach(container => {
  observer.observe(container);
});

Deployment and Build

Islands are typically built using standard tooling:


npm run build:islands

# Output: /dist/islands/
dist/islands/
├── comment-thread.js
├── product-grid.js
├── cart.js
└── search.js

Build tools like Vite, esbuild, or webpack can generate ES modules with tree-shaking, code-splitting, and dynamic imports. The server must serve the /islands directory with appropriate MIME types.

location /islands/ {
  alias /var/www/app/dist/islands/;
  add_header Content-Type 'application/javascript';
  add_header Cache-Control 'public, max-age=31536000';
}

The server can also generate the data-island attributes during SSR or static generation.

// During SSR with Node.js
function renderPage(page) {
  const html = `
    <html>
      <body>
        <main>
          ${page.content.map(item => {
            return `<article data-island="${item.type}" data-src="/islands/${item.type}.js">
                      ${item.html}
                    </article>`;
          }).join('')}
        </main>
      </body>
    </html>
  `;
  return html;
}

This architecture scales well: islands can be versioned, cached independently, and updated without full page rebuilds.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.