How to solve coordination problems in Islands architecture

Islands architecture isolates interactive UI fragments into independently hydrated units. Each island ships only the JavaScript it needs, reducing hydration cost and improving performance.

How to solve coordination problems in Islands architecture

That isolation, however, introduces a coordination problem.

When one island needs to influence another, there is no shared runtime context by default. A cart badge must update when a product island adds an item. A filter island must influence a results island. Authentication state must propagate across boundaries.

This tutorial examines how to solve cross-island coordination without sacrificing the performance guarantees that make Islands architecture compelling. You’ll build a minimal example, see why localStorage polling fails, and replace it with an explicit event-driven model that supports async server updates.

By the end, you’ll understand how to coordinate islands while preserving isolation, testability, and SSR compatibility.

The coordination problem in Islands architecture

Frameworks such as Astro, Qwik, and Fresh implement Islands architecture by isolating interactive components into separate client entry points. Each island owns its state and lifecycle.

That isolation reduces hydration cost, but it removes implicit shared state.

The coordination problem appears when one island needs to react to state owned by another:

  • A cart badge updating after a product is added
  • A notification island responding to auth changes
  • A filter panel affecting a results list

Direct imports between islands break the architectural boundary. Reading global state introduces hidden coupling. Polling introduces timing issues.

We’ll work within three constraints:

  1. Islands remain independently hydrated
  2. No direct cross-imports
  3. Coordination must remain explicit and observable

Project structure

We’ll build a minimal example with two islands: ProductList and CartBadge.

islands-coordination/
  server/
    server.js
  public/
    index.html
    product-list.js
    cart-badge.js
    event-bus.js
  package.json

The wrong approach: Using localStorage as a communication channel

localStorage looks attractive:

  • It’s global
  • It’s persistent
  • It requires no setup

But it creates implicit coupling and timing hazards.

Step 1: Set up the server

Create package.json:

{
  "name": "islands-coordination",
  "type": "module",
  "scripts": {
    "start": "node server/server.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

Install dependencies and create server/server.js:

import express from "express";
import path from "path";
import { fileURLToPath } from "url";

const app = express();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

app.use(express.static(path.join(__dirname, "../public")));

app.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

This serves static assets from public/.

Step 2: Define island entry points

Create public/index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Islands Coordination</title>
  </head>
  <body>
    <div id="product-island"></div>
    <div id="cart-island"></div>

    <script type="module" src="/product-list.js"></script>
    <script type="module" src="/cart-badge.js"></script>
  </body>
</html>

Each island hydrates independently via its own module.

Step 3: Product island writes to localStorage

public/product-list.js:

const root = document.getElementById("product-island");

root.innerHTML = `
  <h2>Products</h2>
  <button data-id="1">Add Product 1</button>
  <button data-id="2">Add Product 2</button>
`;

root.addEventListener("click", (e) => {
  if (e.target.tagName === "BUTTON") {
    const id = e.target.dataset.id;
    const cart = JSON.parse(localStorage.getItem("cart") || "[]");
    cart.push({ id, quantity: 1 });
    localStorage.setItem("cart", JSON.stringify(cart));
  }
});

Step 4: Cart island polls localStorage

public/cart-badge.js:

const root = document.getElementById("cart-island");

root.innerHTML = `
  <h2>Cart</h2>
  <span id="count">0</span> items
`;

const countEl = document.getElementById("count");

function readCart() {
  const cart = JSON.parse(localStorage.getItem("cart") || "[]");
  countEl.textContent = cart.length;
}

readCart();

// Poll every second
setInterval(readCart, 1000);

Why this fails

This implementation introduces several architectural problems:

  • Polling wastes CPU cycles
  • UI updates lag by up to 1s
  • SSR cannot access localStorage
  • Multi-tab behavior becomes inconsistent
  • Hydration order creates race conditions
  • Tests must mock browser storage

Most importantly, state coordination now depends on a browser side effect rather than an explicit contract.

This violates islands’ isolation guarantees.

The correct approach: Event-based communication

Instead of sharing storage, islands communicate through an event bus.

This keeps islands isolated while making coordination explicit and observable.

Step 1: Create an event bus

Create public/event-bus.js:

class EventBus {
  constructor() {
    this.listeners = new Map();
  }

  on(event, handler) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event).add(handler);

    return () => {
      this.listeners.get(event).delete(handler);
    };
  }

  emit(event, payload) {
    if (!this.listeners.has(event)) return;

    for (const handler of this.listeners.get(event)) {
      handler(payload);
    }
  }
}

export const bus = new EventBus();

This defines a narrow communication channel: emit publishes domain events, on subscribes to them, and the returned function allows islands to unsubscribe during teardown.

Step 2: Emit events from ProductList

Replace public/product-list.js:

import { bus } from "./event-bus.js";

const root = document.getElementById("product-island");

root.innerHTML = `
  <h2>Products</h2>
  <button data-id="1">Add Product 1</button>
  <button data-id="2">Add Product 2</button>
`;

root.addEventListener("click", (e) => {
  if (e.target.tagName === "BUTTON") {
    const id = e.target.dataset.id;
    bus.emit("cart:add", { id, quantity: 1 });
  }
});

Step 3: Subscribe in CartBadge

Replace public/cart-badge.js:

import { bus } from "./event-bus.js";

const root = document.getElementById("cart-island");

root.innerHTML = `
  <h2>Cart</h2>
  <span id="count">0</span> items
`;

const countEl = document.getElementById("count");

const cartState = { items: [] };

function render() {
  countEl.textContent = cartState.items.length;
}

bus.on("cart:add", (item) => {
  cartState.items.push(item);
  render();
});

render();

The badge now updates immediately. No polling, no global storage, and no timing dependency on when another island writes state.

Integrating async server updates without breaking isolation

Async logic should remain localized to the emitting island. Consumers should only depend on event contracts.

Update server/server.js

import express from "express";
import path from "path";
import { fileURLToPath } from "url";

const app = express();
app.use(express.json());

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

let cart = [];

app.post("/api/cart", (req, res) => {
  const { id, quantity } = req.body;
  cart.push({ id, quantity });
  res.json({ success: true, cartCount: cart.length });
});

app.use(express.static(path.join(__dirname, "../public")));

app.listen(3000, () => {
  console.log("Server running at http://localhost:3000");
});

Update ProductList with async handling

import { bus } from "./event-bus.js";

const root = document.getElementById("product-island");

root.innerHTML = `
  <h2>Products</h2>
  <button data-id="1">Add Product 1</button>
  <button data-id="2">Add Product 2</button>
  <div id="status"></div>
`;

const statusEl = document.getElementById("status");

root.addEventListener("click", async (e) => {
  if (e.target.tagName === "BUTTON") {
    const id = e.target.dataset.id;
    statusEl.textContent = "Adding...";

    try {
      const res = await fetch("/api/cart", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id, quantity: 1 })
      });

      const data = await res.json();
      bus.emit("cart:updated", { count: data.cartCount });
      statusEl.textContent = "Added";
    } catch (err) {
      bus.emit("cart:error", { message: err.message });
      statusEl.textContent = "Failed";
    }
  }
});

Update CartBadge to react to async events

import { bus } from "./event-bus.js";

const root = document.getElementById("cart-island");

root.innerHTML = `
  <h2>Cart</h2>
  <span id="count">0</span> items
  <div id="error" style="color:red"></div>
`;

const countEl = document.getElementById("count");
const errorEl = document.getElementById("error");

bus.on("cart:updated", ({ count }) => {
  countEl.textContent = count;
  errorEl.textContent = "";
});

bus.on("cart:error", ({ message }) => {
  errorEl.textContent = message;
});

The async boundary now lives inside ProductList. It emits domain events only after resolving the network request. CartBadge does not care whether state came from memory or a server response.

Why the event-driven approach wins

The event bus model changes coordination from implicit side effects into explicit contracts.

  • Performance: No polling and fewer unnecessary updates
  • Isolation: No direct cross-imports between islands
  • SSR compatibility: No reliance on browser-only primitives like localStorage
  • Testability: Events can be simulated without mocking storage APIs
  • Resilience: Failures produce explicit cart:error events

Running the example

Install dependencies and start the server:

npm install
npm start

Open http://localhost:3000. Click the product buttons and observe immediate badge updates.

Stop the server and trigger a request to see error handling in action.

Products In Cart Gif

Error Handling In Action

When to use each approach

localStorage coordination

Use localStorage only if persistence across reloads is required and SSR is not part of the architecture. Even then, prefer event-driven updates with explicit persistence rather than polling.

Avoid localStorage as an island coordination mechanism when UI must update immediately or when you need deterministic behavior.

Event-driven coordination

Use an event bus when islands must remain isolated but still coordinate UI updates. This approach scales better in production because it keeps dependencies explicit, localizes async logic, and supports SSR-friendly contracts.

Conclusion

Coordination is the core architectural challenge in Islands architecture.

Using localStorage as a communication channel introduces hidden coupling, polling overhead, and SSR incompatibility. It appears simple but shifts complexity into runtime timing behavior.

An event-driven model preserves isolation while making coordination explicit, observable, and testable. Async logic remains localized. State transitions become domain events rather than side effects.

The broader principle is simple: coordination in Islands architecture must be explicit, not incidental.

You can build on these patterns by introducing typed events, schema validation for payloads, or swapping the in-memory bus for a framework-level store in environments such as Astro.

The post How to solve coordination problems in Islands architecture appeared first on LogRocket Blog.

 

This post first appeared on Read More