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.

Event bus in the streaming SSR lifecycle Diagram showing three horizontal zones: Server (SSR render), Streaming delivery, and Client (hydrated islands). A central Bus Broker in the client zone connects Island A and Island B via pub/sub arrows. A Deferred Ring Buffer sits between the streaming zone and the broker. SERVER STREAMING CLIENT (hydrated) SSR HTML emit Transfer-Encoding: chunked HTML chunk 1 HTML chunk 2 (island A) HTML chunk 3 (island B) Deferred ring buffer queues pre-hydration events Bus Broker WeakRef registry Island A bus.emit('cart:add', …) Island B bus.on('cart:add', handler)

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.


← Back to Event Delegation in Partially Hydrated Apps