Event Delegation in Partially Hydrated Apps

Frontend teams that adopt partial hydration to cut JavaScript payload quickly hit a concrete problem: a user clicks a button that sits inside a static SSR node — a node whose owning island has not yet loaded its JavaScript. Without a routing layer between the static DOM and the hydrated island, that click either disappears silently or forces eager full-page hydration, which defeats the whole purpose. This page shows how to build a boundary-scoped event delegation layer that intercepts interactions anywhere on the page, routes them correctly to the owning island, and queues interactions that arrive before an island is ready — all without touching unrelated islands or blocking the main thread.


Concept Definition & Scope

Event delegation is the practice of attaching one listener high in the DOM tree and letting native event bubbling carry interactions up to it, rather than wiring n listeners to n elements. In a fully hydrated React or Vue app, the framework manages this internally. In a partially hydrated app the situation is different: large sections of the DOM are inert SSR HTML with no client-side JS at all, and only a handful of discrete islands carry interactive code.

The delegation layer described here sits at the intersection of server-client boundaries and DOM event routing. It is in scope for:

  • Routing user interactions from static SSR nodes to their owning hydrated island.
  • Queuing events that arrive before the target island has initialised.
  • Cleaning up listeners across SPA route transitions or streaming chunk replacements.

It is out of scope for:

The parent topic for this page is Server-Client Boundaries & State Synchronization, which establishes the contracts that make static HTML a valid passive transport layer.


How the Delegation Pipeline Works

The diagram below shows the three-phase lifecycle — attach, resolve, dispatch — that every interaction travels through.

Event delegation pipeline in a partially hydrated app Three-phase flow: 1) User interaction bubbles to the document delegation root. 2) Handler resolves the closest data-island-boundary. 3a) If hydrated, forward CustomEvent to island. 3b) If not yet hydrated, enqueue serialised payload; flush when island emits its ready signal. PHASE 1 — INTERCEPT PHASE 2 — RESOLVE PHASE 3 — DISPATCH User interaction click / keydown / pointer bubbles Document listener capture: true, passive: true closest('[data-island-boundary]') Walk composedPath() for shadow DOM hydrated = true? yes no CustomEvent → island bubbles: false, serialised detail Bounded event queue flush on island:ready signal

The three phases map directly to the code that follows:

  1. Intercept — one capture-phase listener on document, registered during initial script evaluation, before any island hydrates.
  2. Resolveevent.target.closest('[data-island-boundary]') (or event.composedPath() for shadow DOM) finds the owning island and reads its data-hydrated state.
  3. Dispatch — for hydrated islands, a CustomEvent fires immediately on the boundary element; for pending islands, the serialised payload enters a bounded queue that flushes the moment the island signals readiness.

Technical Mechanics

Why a single delegation root

In traditional full-page hydration, every component attaches its own listeners as the framework reconciles the virtual DOM. In a page with 40 interactive elements, that is 40 addEventListener calls during the critical rendering path. A delegation root collapses this to a constant — the listener exists whether there are 4 islands or 400.

Native DOM events bubble through static SSR nodes exactly as they bubble through hydrated ones. The delegation layer exploits this: it attaches once at document and uses data-island-boundary markers embedded in the SSR HTML to locate the owning island without any DOM traversal overhead on the happy path.

Document-level delegation router (framework-agnostic)

/**
 * Attach once during initial script evaluation — before any island hydrates.
 * Uses capture phase so the listener fires before any bubble handlers
 * that may already exist inside partially hydrated content.
 */
export function initIslandEventDelegation() {
  const eventQueue = new Map(); // islandId → IslandEventPayload[]

  function handleDelegatedEvent(e) {
    // Walk composedPath() to cross shadow-DOM boundaries correctly
    const boundary = e.composedPath().find(
      (node) => node instanceof Element && node.hasAttribute('data-island-boundary')
    );
    if (!boundary) return;

    // Only act on explicitly interactive targets to avoid noise
    if (!e.target.matches('[data-interactive]')) return;

    const islandId = boundary.dataset.islandId;
    const isHydrated = boundary.dataset.hydrated === 'true';

    if (isHydrated) {
      // Island is ready — dispatch directly, never re-bubble to document
      boundary.dispatchEvent(new CustomEvent('island-event', {
        bubbles: false,
        detail: {
          type: e.type,
          action: e.target.dataset.action,
          meta: extractEventMeta(e),
        },
      }));
    } else {
      // Buffer for deferred hydration; enforce size cap to protect memory
      if (!eventQueue.has(islandId)) eventQueue.set(islandId, []);
      const bucket = eventQueue.get(islandId);
      if (bucket.length < 50) {
        bucket.push({
          type: e.type,
          action: e.target.dataset.action,
          meta: extractEventMeta(e),
          timestamp: performance.now(),
        });
      }
    }
  }

  // Store the named reference so cleanupIslandDelegation() can remove it exactly
  window.__islandDelegationHandler = handleDelegatedEvent;
  document.addEventListener('click', handleDelegatedEvent, { capture: true, passive: true });
  document.addEventListener('keydown', handleDelegatedEvent, { capture: true });

  // Flush API called by each island after it hydrates
  window.__flushIslandQueue = (islandId) => {
    const queue = eventQueue.get(islandId) ?? [];
    const boundary = document.querySelector(`[data-island-id="${islandId}"]`);
    const TTL_MS = 30_000;
    const now = performance.now();

    queue
      .filter((evt) => now - evt.timestamp < TTL_MS) // drop stale interactions
      .forEach((evt) => {
        boundary?.dispatchEvent(new CustomEvent('island-event', {
          bubbles: false,
          detail: evt,
        }));
      });

    eventQueue.delete(islandId);
  };
}

/** Extract only serialisable primitives — never pass DOM node references across boundaries */
function extractEventMeta(e) {
  return {
    x: e.clientX ?? 0,
    y: e.clientY ?? 0,
    key: e.key ?? null,
    modifiers: { ctrl: e.ctrlKey, shift: e.shiftKey, alt: e.altKey, meta: e.metaKey },
    dataset: { ...e.target.dataset }, // data-* attributes only
  };
}

The serialisation constraint — stripping events to primitive metadata — is identical to the contract described in cross-boundary prop passing: DOM nodes and framework context objects cannot safely travel across hydration boundaries.

Island-side consumer (TypeScript / framework-agnostic)

interface IslandEventPayload {
  type: string;
  action: string | undefined;
  meta: {
    x: number; y: number; key: string | null;
    modifiers: Record<string, boolean>;
    dataset: Record<string, string>;
  };
  timestamp: number;
}

/**
 * Called inside each island's hydration entry point.
 * Marks the boundary as hydrated, drains the queue, then binds the internal handler.
 */
export function bindIslandEventConsumer(
  islandId: string,
  handler: (payload: IslandEventPayload) => void
): () => void {
  const boundary = document.querySelector<HTMLElement>(`[data-island-id="${islandId}"]`);
  if (!boundary) throw new Error(`Hydration boundary "${islandId}" not found in DOM`);

  // Mark as hydrated BEFORE flushing so any events dispatched during flush route correctly
  boundary.dataset.hydrated = 'true';

  // Drain buffered interactions captured before hydration
  window.__flushIslandQueue?.(islandId);

  const listener = (e: Event) => {
    const detail = (e as CustomEvent<IslandEventPayload>).detail;
    if (!detail?.type) return; // reject malformed payloads
    handler(detail);
  };

  boundary.addEventListener('island-event', listener);

  // Return a cleanup function for framework unmount hooks
  return () => boundary.removeEventListener('island-event', listener);
}

Astro example — wiring delegation to a client:visible island

---
// src/components/CartButton.astro
// SSR shell — rendered as static HTML; the island hydrates when scrolled into view
---
<div
  data-island-boundary
  data-island-id="cart-button"
  data-hydrated="false"
>
  
  <button data-interactive data-action="add-to-cart" class="cart-btn">
    Add to cart
  </button>
</div>

<script>
  // client:visible — Astro loads this module only when the element enters the viewport
  import { bindIslandEventConsumer } from '../lib/islandDelegation';

  bindIslandEventConsumer('cart-button', (payload) => {
    if (payload.action === 'add-to-cart') {
      // Optimistic update runs here — see /optimistic-updates-without-full-hydration/
      document.dispatchEvent(new CustomEvent('cart:add', { detail: payload.meta.dataset }));
    }
  });
</script>

Qwik — delegation is implicit, but queue flushing still applies

Qwik’s resumable architecture serialises listener metadata into HTML attributes during SSR and resumes them on first interaction without a traditional hydration step. The delegation queue is therefore less critical, but bounded buffering is still useful for interactions that fire before the Qwik runtime loads its lazy chunk:

// src/components/CartButton.tsx (Qwik)
import { component$, useSignal, $ } from '@builder.io/qwik';

export const CartButton = component$(() => {
  const pending = useSignal(false);

  // $() serialises the handler into the HTML — no hydration boundary flush needed
  const addToCart = $(async (event: PointerEvent) => {
    pending.value = true;
    // Qwik resumes execution here on first interaction
    await fetch('/api/cart', { method: 'POST', body: JSON.stringify({ id: 'sku-123' }) });
    pending.value = false;
  });

  return (
    <button
      data-interactive
      data-action="add-to-cart"
      onClick$={addToCart}
      disabled={pending.value}
    >
      {pending.value ? 'Adding…' : 'Add to cart'}
    </button>
  );
});

Comparison: Delegation Strategies Across Approaches

Approach Listener count Works pre-hydration? Shadow DOM safe? Memory on nav When to use
Per-component addEventListener O(n) DOM nodes No — listener attached during hydration Yes, if placed inside shadow root Leaks without explicit cleanup Small islands with simple, synchronous hydration
Document delegation (this page) O(1) constant Yes — listener active from script load Yes, via composedPath() One teardown call removes all Any app with deferred or progressive hydration
Framework synthetic events (React, Qwik) Managed internally Qwik: yes; React: no Framework-dependent Managed by framework Intra-framework islands; not suitable for cross-framework boundary routing
BroadcastChannel / SharedWorker bus O(1) per tab No — requires worker bootstrap N/A (off-thread) Persistent across nav; explicit close needed Multi-tab coordination; heavy cross-island messaging
Custom EventBus (pub/sub) O(subscribers) Depends on initialisation order N/A Requires explicit unsubscribe on teardown Large apps with many islands; see global event buses for island communication

Step-by-Step Integration Pattern

Step 1 — Add boundary markers to SSR HTML

Every interactive island shell needs two attributes: data-island-boundary (the delegation hook) and data-island-id (a stable, unique key). Add them server-side so they are present in the initial HTML stream, before any JavaScript runs.

<!-- Server-rendered shell (any templating language) -->
<section
  data-island-boundary
  data-island-id="product-gallery"
  data-hydrated="false"
>
  <!-- Static SSR content rendered here -->
  <img src="/product.jpg" alt="Product image" />
  <button data-interactive data-action="open-zoom">View full size</button>
</section>

Step 2 — Initialise the delegation root early

Call initIslandEventDelegation() from a script loaded with defer or as an ES module in <head>. It must execute before the user can interact with the page.

<head>
  <!-- Load delegation bootstrap before any island hydration scripts -->
  <script type="module" src="/js/delegation-bootstrap.js"></script>
</head>
// /js/delegation-bootstrap.js
import { initIslandEventDelegation } from './islandDelegation.js';
initIslandEventDelegation(); // attaches document listener immediately

Step 3 — Bind each island’s consumer on hydration

Inside every island’s entry point (the script that runs when the island hydrates), call bindIslandEventConsumer. This marks the boundary as hydrated and flushes any queued events.

// Island entry point — e.g. loaded via Astro client:visible
import { bindIslandEventConsumer } from '/js/islandDelegation.js';

const cleanup = bindIslandEventConsumer('product-gallery', (payload) => {
  if (payload.action === 'open-zoom') openZoomModal(payload.meta.dataset);
});

// Store cleanup for Step 5
window.__islandCleanups ??= new Map();
window.__islandCleanups.set('product-gallery', cleanup);

Step 4 — Verify the queue drains correctly

Open Chrome DevTools → Console, then interact with a slow-loading island before its JS arrives. You should see zero console errors. After hydration, island-event CustomEvents appear on the boundary element:

// Paste in DevTools console to monitor island events on a specific boundary
document.querySelector('[data-island-id="product-gallery"]')
  .addEventListener('island-event', (e) => console.log('island-event', e.detail));

Step 5 — Tear down on navigation

For SPA-style routing, clean up before the next route renders to prevent orphaned global listeners and memory-retaining closures.

// /js/navigation-teardown.js
import { cleanupIslandDelegation } from './islandDelegation.js';

// Astro View Transitions hook; adapt to your router's equivalent
document.addEventListener('astro:before-swap', () => {
  // Remove the document listener and purge streaming buffers
  cleanupIslandDelegation();

  // Run each island's individual cleanup
  window.__islandCleanups?.forEach((fn) => fn());
  window.__islandCleanups?.clear();
});
// cleanupIslandDelegation implementation
export function cleanupIslandDelegation() {
  if (window.__islandDelegationHandler) {
    document.removeEventListener('click', window.__islandDelegationHandler, { capture: true });
    document.removeEventListener('keydown', window.__islandDelegationHandler, { capture: true });
    delete window.__islandDelegationHandler;
  }
  if (window.__streamingEventBuffer) {
    window.__streamingEventBuffer.purge();
  }
  // Reset hydration markers so re-entry starts clean
  document.querySelectorAll('[data-island-boundary]').forEach((el) => {
    (el as HTMLElement).dataset.hydrated = 'false';
  });
}

Measurement & Validation

Performance marks around delegation

Wrap the delegation setup and queue flush with performance.mark calls to measure actual routing overhead in production RUM data.

export function initIslandEventDelegation() {
  performance.mark('island-delegation:init-start');
  // ... setup code ...
  performance.mark('island-delegation:init-end');
  performance.measure('island-delegation:init', 'island-delegation:init-start', 'island-delegation:init-end');
}

// Inside flushIslandQueue:
performance.mark(`island-delegation:flush-start:${islandId}`);
// ... flush ...
performance.mark(`island-delegation:flush-end:${islandId}`);
performance.measure(
  `island-delegation:flush:${islandId}`,
  `island-delegation:flush-start:${islandId}`,
  `island-delegation:flush-end:${islandId}`
);

Chrome DevTools workflow

  1. Performance panel — record a 10-second trace covering page load and two interactions. Filter Event entries by island-event. Confirm only the delegation root fires during the pre-hydration window, not per-element handlers.
  2. Memory snapshot — take a heap snapshot before and after hydrating all islands. Verify EventListener object count scales with island count, not with individual DOM node count. If you see hundreds of listeners on HTMLButtonElement, per-component attachment is leaking through.
  3. INP via Web Vitals extension — the delegation pattern eliminates synchronous listener binding on the critical rendering path. Target INP < 200ms; values above 500ms indicate either queue saturation or synchronous work inside island handlers.
  4. Network throttling (Slow 4G) — interact with a static node while the island JS is still loading. The DevTools console must stay clean — no Uncaught TypeError, no hydration mismatch warnings. After the script arrives, the queued action should replay automatically.

Failure Modes

Failure mode 1 — Events dropped during streaming because data-island-boundary is not in the initial HTML chunk

Symptom: Interactions on content that arrives in later streaming chunks are silently ignored; no island-event fires.

Root cause: The SSR template adds boundary attributes inside a streamed <Suspense> boundary (Next.js) or later HTML chunk (Astro). When the user clicks, closest('[data-island-boundary]') finds nothing because the parent marker has not yet been inserted.

Fix: Render boundary markers in the outer shell, not inside the deferred chunk. The outer shell streams first; the content inside can defer.

<!-- WRONG — boundary marker inside deferred chunk -->
<Suspense fallback={<Skeleton />}>
  <section data-island-boundary data-island-id="comments">...</section>
</Suspense>

<!-- CORRECT — outer wrapper carries the boundary marker in the synchronous shell -->
<section data-island-boundary data-island-id="comments" data-hydrated="false">
  <Suspense fallback={<Skeleton />}>
    <CommentList />
  </Suspense>
</section>

Failure mode 2 — Stale listeners after SPA navigation cause duplicate island-event dispatches

Symptom: After navigating away and back, each click triggers the handler twice (or more), with state updates doubling up.

Root cause: The document listener from the previous route was never removed. On re-entry, a second call to initIslandEventDelegation() registers a duplicate listener alongside the orphaned one.

Fix: Guard initialisation with the named handler reference, and always call cleanupIslandDelegation() in the router’s before-navigation hook.

export function initIslandEventDelegation() {
  // Idempotency guard — do not attach a second listener if one is already active
  if (window.__islandDelegationHandler) return;
  // ... rest of setup
}

Failure mode 3 — Race condition: island emits ready before bindIslandEventConsumer runs

Symptom: Queue never flushes; queued interactions are lost even though the island looks hydrated.

Root cause: The island’s framework lifecycle (e.g. Astro’s connectedCallback, React’s useEffect) marks data-hydrated="true" and calls a synchronous signal before the delegation module has finished importing.

Fix: Reverse the order — call bindIslandEventConsumer (which sets data-hydrated="true" internally) as the first statement in the island’s entry point, before any async operations.

// Island entry point — hydration-safe ordering
import { bindIslandEventConsumer } from '/js/islandDelegation.js';

// Step 1: register consumer and flush queue FIRST
const cleanup = bindIslandEventConsumer('my-island', handleIslandEvent);

// Step 2: now safe to run async initialisation
const data = await fetchInitialState();
renderIsland(data);

← Back to Server-Client Boundaries & State Synchronization