Implementing Global Event Buses for Island Communication
When two islands need to coordinate β a cart counter reacting to a product-picker selection, or a notification banner responding to a background data fetch β DOM event bubbling alone breaks down. Islands are isolated hydration scopes, so a CustomEvent dispatched inside one island cannot reliably reach another without explicit routing. A global pub/sub event bus solves this, but naively bolting one on top of event delegation in partially hydrated apps produces zombie listeners, dropped events during streaming chunk delivery, and main-thread blocks that erase the TTI gains islands are meant to deliver. This guide shows framework maintainers and performance engineers how to build a bus that avoids every one of those traps.
Prerequisites
Architectural overview
Before touching code, it helps to see where the event bus sits relative to the streaming SSR lifecycle. The diagram below shows the three zones: the server rendering pass, the streaming chunk delivery window, and the hydrated client. The bus broker lives in the client zone and mediates between islands that may hydrate at different times.
Step 1 β Define boundary enforcement rules
Every cross-island message must satisfy three constraints before a line of bus code is written.
Dispatch contract classification. Synchronous (microtask-bound) dispatch is acceptable only for updates within the same islandβs reconciliation cycle. Cross-island payloads must be deferred via queueMicrotask or requestAnimationFrame so they do not block the main thread during streaming SSR chunk parsing.
Payload routing. All messages travel through the centralized broker. Direct DOM event bubbling between islands couples them to shared ancestor nodes β nodes that may not yet exist when a late-arriving streaming chunk inserts the target island.
Serialization limits. Enforce a 32 KB cap and strip non-serializable types before dispatch. Passing a Map, Set, DOM node, Symbol, or function across the boundary causes silent failures in environments that use structuredClone or MessagePort under the hood.
Audit: coupling and boundary check
Before writing the bus, verify the current event landscape with two DevTools commands run in the Console:
// Lists all non-framework listeners on the document body.
// Any 'message' or 'customEvent' type outside your bus registry signals DOM bubbling leakage.
getEventListeners(document.body);
// Measure serialization cost of a representative payload.
// If delta > 8 ms, reduce payload depth or add lazy field resolution.
performance.measure('serialize', () => structuredClone(payload));
Expected output: getEventListeners returns an empty object or only known framework bindings. performance.measure logs a PerformanceMeasure entry with duration < 8.
Step 2 β Build a WeakRef-backed listener registry
Standard Map or array-backed registries retain the listener function indefinitely. When an island unmounts, the framework may discard its closure but the registry still holds a strong reference, preventing garbage collection and leaking ~0.2 MB per island lifecycle in a high-churn UI.
A WeakRef-backed registry lets the GC reclaim dead listeners automatically. An AbortController signal wired to the frameworkβs cleanup hook handles eager teardown without requiring an explicit bus.off() call.
// event-bus.ts β shared singleton, import once and re-export from a module entry point
type Listener<T> = (payload: T) => void;
export class EventBus {
// WeakRef buckets: dead refs are purged on next emit() or explicit gc()
private registry = new Map<string, WeakRef<Listener<unknown>>[]>();
/**
* Register a listener. Returns an AbortController whose signal tears down
* the listener when aborted β wire this to your framework's cleanup hook:
* Astro: island.addEventListener('astro:unmount', () => ctrl.abort())
* React: useEffect(() => () => ctrl.abort(), [])
* SvelteKit: onDestroy(() => ctrl.abort())
*/
on<T>(event: string, listener: Listener<T>): AbortController {
const controller = new AbortController();
const ref = new WeakRef(listener as Listener<unknown>);
const bucket = this.registry.get(event) ?? [];
bucket.push(ref);
this.registry.set(event, bucket);
controller.signal.addEventListener('abort', () => {
const current = this.registry.get(event);
if (current) {
// Remove this specific ref; leave live siblings intact
this.registry.set(event, current.filter(r => r !== ref));
}
});
return controller;
}
emit<T>(event: string, payload: T): void {
const bucket = this.registry.get(event);
if (!bucket) return;
for (const ref of bucket) {
const listener = ref.deref();
if (!listener) continue; // ref is dead β skip and leave for gc()
try {
listener(payload);
} catch (err) {
// Isolate listener errors so one bad subscriber cannot silence others
console.error(`[EventBus] listener error on "${event}":`, err);
}
}
}
/** Purge all dead WeakRefs. Call periodically or after a known unmount burst. */
gc(): void {
for (const [event, bucket] of this.registry.entries()) {
const live = bucket.filter(r => r.deref() !== undefined);
if (live.length === 0) this.registry.delete(event);
else this.registry.set(event, live);
}
}
}
// Export a singleton so every island shares the same registry
export const bus = new EventBus();
Payload serialization guard
Validate before dispatch to surface problems at the call site rather than failing silently deep inside a structuredClone or postMessage path.
// serialize.ts
export function serializePayload<T extends Record<string, unknown>>(payload: T): T {
const sanitized = { ...payload };
for (const key in sanitized) {
const val = sanitized[key];
// Strip types that cannot cross structured-clone or JSON boundaries
if (
typeof val === 'function' ||
val instanceof Node || // DOM node reference β never pass across islands
typeof val === 'symbol' ||
val instanceof Map ||
val instanceof Set
) {
delete sanitized[key];
}
}
const bytes = new TextEncoder().encode(JSON.stringify(sanitized)).length;
if (bytes > 32_000) {
// Hard stop: payloads this large block the main thread during partial hydration
throw new RangeError(
`[EventBus] Payload for exceeds 32 KB (${bytes} B). Reduce size or stream separately.`
);
}
return sanitized;
}
Expected output after this step. Dispatching a 40 KB payload throws RangeError immediately. Dispatching a clean object under the cap calls listeners synchronously without console errors.
Step 3 β Add a deferred ring buffer for streaming SSR
Streaming SSR delivers HTML in chunks. Island Bβs bus.on() call executes only when its chunk arrives and its hydration boundary activates. If Island A emits cart:add before that chunk lands, the event is lost with a naive bus.
A bounded ring buffer with a hydration gate queues events emitted before a target island is ready, then replays them in insertion order once hydrate() is called.
// deferred-bus.js β wraps the core EventBus singleton
import { bus, serializePayload } from './event-bus.js';
export class DeferredBus {
#buffer = [];
#maxSize = 500; // evict oldest when full
#ttlMs = 5_000; // drop events older than 5 s on flush
#ready = false;
#resolveReady;
/** Resolves when hydrate() is called β await this in late-arriving islands */
readyPromise = new Promise(resolve => { this.#resolveReady = resolve; });
emit(event, payload) {
// Validate and sanitize before queuing or dispatching
const safe = serializePayload(payload);
if (this.#ready) {
// Bus is hydrated: dispatch directly, no buffering overhead
bus.emit(event, safe);
return;
}
// Buffer pre-hydration event with a timestamp for TTL eviction
if (this.#buffer.length >= this.#maxSize) {
this.#buffer.shift(); // ring eviction: discard oldest
}
this.#buffer.push({ event, payload: safe, ts: performance.now() });
}
/**
* Call this when the owning island's hydration boundary resolves.
* In Astro: listen for the 'astro:page-load' lifecycle event.
* In Next.js App Router: call inside a useEffect with an empty dep array.
*/
hydrate() {
this.#ready = true;
this.#resolveReady();
this.#flush();
}
#flush() {
const now = performance.now();
// Replay only events within TTL, in the order they were queued
const active = this.#buffer.filter(e => now - e.ts < this.#ttlMs);
for (const { event, payload } of active) {
bus.emit(event, payload);
}
this.#buffer = [];
}
}
export const deferredBus = new DeferredBus();
Framework integration snippets
In an Astro island (src/components/CartCounter.astro):
---
// Server side: nothing to do β the bus is client-only
---
<div id="cart-counter" data-count="0"></div>
<script>
import { deferredBus } from '../lib/deferred-bus.js';
// Signal that this island is ready to receive buffered events
deferredBus.hydrate();
deferredBus.readyPromise.then(() => {
const ctrl = bus.on('cart:add', ({ sku, qty }) => {
// Update the counter DOM node β island is guaranteed hydrated here
document.getElementById('cart-counter').dataset.count =
String(Number(document.getElementById('cart-counter').dataset.count) + qty);
});
// Astro fires 'astro:unmount' when the island leaves the DOM
document.addEventListener('astro:unmount', () => ctrl.abort(), { once: true });
});
</script>
In a React 18 'use client' component:
'use client';
import { useEffect } from 'react';
import { bus } from '../lib/event-bus';
import { deferredBus } from '../lib/deferred-bus';
export function CartCounter() {
useEffect(() => {
// Activate the deferred bus for this island
deferredBus.hydrate();
const ctrl = bus.on<{ sku: string; qty: number }>('cart:add', ({ qty }) => {
setCount(c => c + qty);
});
// React cleanup = AbortController abort = listener removed from WeakRef bucket
return () => ctrl.abort();
}, []);
// β¦render
}
Expected output after this step. Emit cart:add before CartCounter mounts. After mount the counter increments by the buffered quantity. Events older than 5 seconds or beyond the 500-event cap are silently evicted, visible as missing increments during a deliberately delayed hydration smoke test.
Step 4 β Route through the delegation anchor
The event bus handles cross-island pub/sub. For interactions originating on static, not-yet-hydrated DOM nodes, align the bus dispatch with the event delegation in partially hydrated apps pattern: attach a single data-bus-bridge attribute to a static DOM anchor and route pointer events through it until the target islandβs hydration boundary resolves.
<!-- SSR-rendered shell β present before any island hydrates -->
<div data-bus-bridge="cart-add" data-sku="ABC-123" data-qty="1">
Add to cart
</div>
// bridge.js β runs synchronously during initial script evaluation, before islands hydrate
document.addEventListener('click', e => {
const bridge = e.target.closest('[data-bus-bridge]');
if (!bridge) return;
const event = bridge.dataset.busBridge; // 'cart-add'
const payload = { ...bridge.dataset }; // all data-* attributes as plain strings
delete payload.busBridge; // remove the routing key itself
// DeferredBus queues the event if the target island isn't hydrated yet
deferredBus.emit(event, payload);
}, { passive: true });
Verification
Run the following checks to confirm the implementation is working correctly.
Heap snapshot β no zombie listeners. In Chrome DevTools β Memory panel, take a heap snapshot. Mount and unmount an island 50 times while dispatching events. Take a second snapshot. Filter the diff by EventBus or the listener function name. The retained size delta should be under 0.5 MB; a growing delta indicates WeakRef or AbortController wiring is missing.
Performance mark β dispatch latency. Wrap a batch of 100 dispatches in performance.mark / performance.measure:
performance.mark('bus-start');
for (let i = 0; i < 100; i++) bus.emit('test', { i });
performance.mark('bus-end');
performance.measure('100 dispatches', 'bus-start', 'bus-end');
// Target: duration < 16 ms (one frame budget)
Flush ordering β buffer drain. Open DevTools Network β Waterfall and identify the streaming chunk timestamps for each island. Confirm that deferredBus.hydrate() fires after the islandβs chunk is inserted (visible as DOMContentLoaded sub-events in the Performance timeline) and that the first bus.emit() in the #flush loop matches the earliest queued event timestamp.
TTI regression β constrained network. Throttle to Slow 3G. Dispatch 500+ events pre-hydration. Measure INP and FCP via the web-vitals library. Target: TTI regression under +120 ms compared to a baseline without the bus.
Troubleshooting
Listener fires after island unmounts
Symptom: A listener executes and throws because its closed-over DOM reference is gone.
Root cause: The AbortController returned by bus.on() was never aborted on unmount, or the framework cleanup hook was not wired to it.
Fix: Confirm the teardown path for your framework. In React: useEffect(() => () => ctrl.abort(), []). In Astro: document.addEventListener('astro:unmount', () => ctrl.abort(), { once: true }). In SvelteKit: onDestroy(() => ctrl.abort()). Then call bus.gc() to immediately purge any stale WeakRef entries.
Events emitted before hydration are silently dropped
Symptom: Island A emits an event; Island Bβs handler never runs, even after Island B hydrates.
Root cause: The plain EventBus (not DeferredBus) was used. Without a ring buffer, pre-hydration events have no recipient and are discarded.
Fix: Replace bus.emit() with deferredBus.emit() on the publisher side, and ensure Island B calls deferredBus.hydrate() inside its mount hook. Verify flush ordering in the Performance timeline β hydrate() must fire after the islandβs streaming chunk is inserted.
RangeError: Payload exceeds 32 KB
Symptom: serializePayload throws at the dispatch call site.
Root cause: The payload contains a deeply nested object, a large array (e.g., a full product catalog), or blob-style data that should travel via a different mechanism.
Fix: Move large datasets to a shared store (a sessionStorage key, a Zustand slice, or an atom in Jotai) and emit only the key or mutation descriptor over the bus. For binary data, use a MessageChannel MessagePort directly between islands.
Related
- Event Delegation in Partially Hydrated Apps β the delegation anchor pattern that routes static-node interactions to the bus before target islands hydrate.
- Cross-Boundary Prop Passing β covers passing serialized state from the server into island props at render time, complementing runtime bus messaging.
- Passing Complex Objects from Server to Client Islands β deep-dives on structured-clone constraints and the same 32 KB serialization boundary that the bus enforces.
β Back to Event Delegation in Partially Hydrated Apps