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.
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:
- Islands remain independently hydrated
- No direct cross-imports
- 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:errorevents
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.
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




