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 Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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:
Direct imports between islands break the architectural boundary. Reading global state introduces hidden coupling. Polling introduces timing issues.
We’ll work within three constraints:
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
localStorage looks attractive:
But it creates implicit coupling and timing hazards.
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/.
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.
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));
}
});
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);
This implementation introduces several architectural problems:
localStorageMost importantly, state coordination now depends on a browser side effect rather than an explicit contract.
This violates islands’ isolation guarantees.
Instead of sharing storage, islands communicate through an event bus.
This keeps islands isolated while making coordination explicit and observable.
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.
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 });
}
});
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.
Async logic should remain localized to the emitting island. Consumers should only depend on event contracts.
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");
});
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";
}
}
});
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.
The event bus model changes coordination from implicit side effects into explicit contracts.
localStoragecart:error eventsInstall 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.


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.
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.
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.

Signal Forms in Angular 21 replace FormGroup pain and ControlValueAccessor complexity with a cleaner, reactive model built on signals.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the February 25th issue.

Explore how the Universal Commerce Protocol (UCP) allows AI agents to connect with merchants, handle checkout sessions, and securely process payments in real-world e-commerce flows.

React Server Components and the Next.js App Router enable streaming and smaller client bundles, but only when used correctly. This article explores six common mistakes that block streaming, bloat hydration, and create stale UI in production.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now