Event Bus Patterns for Decoupled Apps #

When two independently deployed shells need to talk — a header remote signals a logout, a cart remote pushes an item count, a theme switcher flips every panel at once — the naive answer is to wire them together directly. That works until the third team ships. Direct references between remotes mean a change in one shell forces a redeploy of the others, which is exactly the coupling Cross-App State & Context Sharing exists to eliminate.

An event bus inverts that relationship. Instead of remote A calling remote B, both publish and subscribe to named events on a shared channel. Neither knows who is listening, and adding a new consumer never touches the producer. This guide walks through building a typed bus end to end: the build-time configuration that keeps a single instance alive across federated bundles, the host-and-remote wiring, the failure modes that bite in production, and how to test and roll it out safely. For implementation deep-dives, see implementing a global event bus with RxJS and the BroadcastChannel vs RxJS vs CustomEvent comparison below.

What breaks without a bus #

The failure mode is gradual, then sudden. Early on, two remotes share state through a global variable or a direct import, and it feels fine. As the system grows, three problems compound.

First, coupling leaks into deploy schedules. If the cart remote imports a type or function from the header remote, you can no longer deploy them independently — the whole point of the architecture. Teams start coordinating releases, and the org slows to the speed of its slowest shell.

Second, state goes stale silently. A remote that mounts late misses every event fired before it arrived. The user logs in, the header updates, but the recommendations panel — loaded a half-second later — never hears about it and renders a logged-out view.

Third, memory grows without bound. Micro-frontends mount and unmount as users navigate. Each mount that subscribes without a matching teardown leaks a listener, and over a long session the bus accumulates thousands of dead handlers firing against unmounted components.

A well-designed bus solves all three: producers and consumers stay decoupled, late subscribers can replay the last known state, and subscriptions carry their own cleanup.

Event bus mediating publishers and subscribers across remotes Three remote apps publish events into a central typed bus, which fans them out to subscribing remotes without direct references between apps. Typed Event Bus singleton · validated Header remote publishes user:login Cart remote publishes cart:sync Recs remote subscribes user:login Theme remote pub + sub theme:set
Remotes publish and subscribe through one shared bus — no remote holds a reference to another.

Objectives #

This guide builds toward a bus that satisfies five concrete requirements:

Choosing a transport #

Before any code, decide what physically carries an event from publisher to subscriber. The bus contract — publish and subscribe over a typed event map — stays identical across all three; only the transport underneath changes. That separation matters: you can start on CustomEvent and swap in BroadcastChannel later without touching a single remote, because the remotes only ever import publish/subscribe.

Transport Reach Ordering Replay Best when
CustomEvent on a DOM target Same document only Synchronous, dispatch order Manual (last-seen map) Zero-dependency in-page bus; you control a shared window/element.
RxJS Subject / ReplaySubject Same JS realm Synchronous, emission order Built in via ReplaySubject(1) You want operator composition — filter, debounceTime, scan, takeUntil.
BroadcastChannel All tabs/iframes of the same origin Async (microtask/task), per-channel order Manual; messages are not retained Logout, theme, or cart state must sync across tabs.

A common production setup layers them: an RxJS Subject is the in-page hub, and a thin BroadcastChannel bridge mirrors a whitelist of events to other tabs. The deeper trade-off — including bundle cost and worker support — is the subject of BroadcastChannel vs RxJS vs CustomEvent for an event bus.

Setup and configuration #

Two things have to be right before any event flows: the bus must be a true singleton across bundles, and its events must be typed.

Keep the bus a singleton across remotes #

If each remote bundles its own copy of the bus module, you get several isolated buses and zero cross-app routing. Module Federation’s shared config solves this by hoisting one instance into the host runtime.

// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host_app',
      shared: {
        '@acme/event-bus': {
          singleton: true,      // one instance for the whole page
          strictVersion: true,  // fail loudly on incompatible versions
          requiredVersion: '^2.1.0',
        },
      },
    }),
  ],
};

With singleton: true, Webpack resolves @acme/event-bus once and shares that module object with every remote. strictVersion: true turns a silent mismatch into a build-time error, which is what you want — a half-shared bus is harder to debug than a broken build. The same options exist for the Vite federation plugin; getting singletons right is the same discipline covered in alternatives to prop drilling in distributed UIs.

Define a typed event map #

Untyped events are where decoupled systems rot. Give every event a name and a payload shape in one place, then derive both the TypeScript types and a runtime validator from it.

// events.ts
import { z } from 'zod';

export const eventSchemas = {
  'user:login': z.object({ userId: z.string(), name: z.string() }),
  'cart:sync': z.object({ count: z.number().int().nonnegative() }),
  'theme:set': z.object({ theme: z.enum(['light', 'dark']) }),
} as const;

export type EventName = keyof typeof eventSchemas;
export type EventPayload<K extends EventName> = z.infer<(typeof eventSchemas)[K]>;

The as const map is the single source of truth. TypeScript now knows that cart:sync carries a count: number, so a typo or a wrong payload fails at compile time. Zod handles what the compiler can’t: a remote deployed last week might emit a shape this host has never seen, and runtime validation catches it before it corrupts downstream state.

Model the wire envelope as a discriminated union #

The payload map above types what each event carries. The other half of the contract is what travels on the wire: an envelope tagged with the event name so a single subscriber can switch over every event with the compiler narrowing the payload for each branch. A discriminated union — the standard TypeScript 5.x pattern for exactly this — makes that exhaustive and safe.

// envelope.ts
import { EventName, EventPayload } from './events';

// The contract version lets old and new remotes negotiate at runtime.
export const BUS_SCHEMA_VERSION = 2 as const;

export type BusEvent = {
  [K in EventName]: {
    type: K;
    payload: EventPayload<K>;
    v: typeof BUS_SCHEMA_VERSION; // schema version stamped on every event
    source: string;               // remote id, for echo suppression
    ts: number;                   // epoch ms, for ordering/debugging
    crossTab?: boolean;           // set when re-injected from another tab
  };
}[EventName];

// Exhaustive narrowing: TS errors if a new event type is unhandled.
export function describe(e: BusEvent): string {
  switch (e.type) {
    case 'user:login': return `login ${e.payload.userId}`;
    case 'cart:sync':  return `cart ${e.payload.count}`;
    case 'theme:set':  return `theme ${e.payload.theme}`;
    default: {
      const _exhaustive: never = e; // compile error if a case is missing
      return _exhaustive;
    }
  }
}

Two fields earn their place beyond type and payload. v lets a consumer ignore — rather than mis-parse — an event from a remote on a newer contract, and source lets a remote drop its own echoes, which is what breaks the circular-dispatch loops covered below. Stamping ts costs nothing and makes ordering bugs visible in a log.

Integration: host and remotes #

The bus itself is small. The discipline is in how each side wires into it.

The bus implementation #

// bus.ts
import { eventSchemas, EventName, EventPayload } from './events';

type Handler<K extends EventName> = (payload: EventPayload<K>) => void;
const CHANNEL = 'acme:bus';

export function publish<K extends EventName>(type: K, payload: EventPayload<K>) {
  const result = eventSchemas[type].safeParse(payload);
  if (!result.success) {
    console.error(`[bus] invalid payload for ${type}`, result.error.issues);
    return; // drop, don't crash the publisher
  }
  window.dispatchEvent(new CustomEvent(CHANNEL, { detail: { type, payload } }));
}

export function subscribe<K extends EventName>(type: K, handler: Handler<K>): () => void {
  const listener = (e: Event) => {
    const { type: t, payload } = (e as CustomEvent).detail;
    if (t === type) handler(payload as EventPayload<K>);
  };
  window.addEventListener(CHANNEL, listener);
  return () => window.removeEventListener(CHANNEL, listener); // teardown handle
}

Two design choices matter here. publish validates and drops invalid payloads rather than throwing, so a buggy producer can’t take down the page. And subscribe returns its own unsubscribe function — the only contract that makes leak-free teardown possible.

An RxJS-backed bus with built-in replay #

When you want operator composition and free late-mount replay, back the same contract with an RxJS 7 Subject. A single ReplaySubject(1) per event type gives every late subscriber the last value automatically, so the manual cache shown later becomes unnecessary on this transport.

// rxjs-bus.ts
import { ReplaySubject, filter, map } from 'rxjs';
import { eventSchemas, EventName, EventPayload } from './events';
import { BusEvent, BUS_SCHEMA_VERSION } from './envelope';

const SOURCE = `${location.host}#${crypto.randomUUID()}`;
// One replaying stream of every event; bufferSize 1 = last value per stream.
const stream$ = new ReplaySubject<BusEvent>(1);

export function publish<K extends EventName>(type: K, payload: EventPayload<K>) {
  const parsed = eventSchemas[type].safeParse(payload);
  if (!parsed.success) {
    console.error(`[bus] invalid payload for ${type}`, parsed.error.issues);
    return;
  }
  stream$.next({ type, payload: parsed.data, v: BUS_SCHEMA_VERSION, source: SOURCE, ts: Date.now() });
}

export function on<K extends EventName>(type: K) {
  return stream$.pipe(
    filter((e): e is Extract<BusEvent, { type: K }> => e.type === type),
    filter((e) => e.v === BUS_SCHEMA_VERSION), // ignore events from a newer contract
    map((e) => e.payload),
  );
}

export function subscribe<K extends EventName>(type: K, handler: (p: EventPayload<K>) => void) {
  const sub = on(type).subscribe(handler);
  return () => sub.unsubscribe(); // same teardown contract as the CustomEvent bus
}

Because on() returns an Observable, RxJS consumers get the full operator toolbox for free — debounceTime a noisy channel, scan to accumulate, takeUntil(destroy$) to bind teardown to a lifecycle. The deeper RxJS patterns, including multi-stream coordination, live in implementing a global event bus with RxJS.

Expose the bus as a Module Federation singleton #

The contract is worthless if two remotes import two different stream$ instances. Beyond the shared config above, the bus module must also defend itself against accidental duplication by parking its instance on a well-known global and reusing it if one already exists. This survives the case where one remote pins the wrong version and dodges the shared graph entirely.

// singleton.ts
import { ReplaySubject } from 'rxjs';
import { BusEvent } from './envelope';

const KEY = Symbol.for('@acme/event-bus@2');
type Globals = typeof globalThis & { [KEY]?: ReplaySubject<BusEvent> };
const g = globalThis as Globals;

// Reuse the existing stream if any bundle already created one this page.
export const stream$: ReplaySubject<BusEvent> =
  g[KEY] ?? (g[KEY] = new ReplaySubject<BusEvent>(1));

if (process.env.NODE_ENV !== 'production' && g[KEY] !== stream$) {
  console.warn('[bus] duplicate instance detected — check shared singleton config');
}

Symbol.for interns the key in the global symbol registry, so even modules from different realms or bundle copies resolve to the same symbol and therefore the same ReplaySubject. This belt-and-suspenders pattern is what keeps the bus working when the federation shared config is almost right — the same singleton discipline that governs shared context providers across remotes.

Wiring a React remote #

Bind subscriptions to component lifecycle so they clean themselves up. The unsubscribe handle returned above plugs straight into a useEffect cleanup.

// CartBadge.tsx
import { useEffect, useState } from 'react';
import { subscribe } from '@acme/event-bus';

export function CartBadge() {
  const [count, setCount] = useState(0);
  useEffect(() => subscribe('cart:sync', ({ count }) => setCount(count)), []);
  return <span className="badge">{count}</span>;
}

Returning subscribe(...) directly from useEffect means React calls the unsubscribe function on unmount — no dangling listener. Angular remotes do the equivalent by calling the teardown in ngOnDestroy; Vue in onUnmounted.

Solving the late-mount problem #

A remote that mounts after user:login fired never sees it. Keep a small cache of the last payload per event and replay it on subscribe. RxJS makes this a one-liner with ReplaySubject(1); with the CustomEvent bus you keep a plain map:

const lastSeen = new Map<EventName, unknown>();
window.addEventListener(CHANNEL, (e) => {
  const { type, payload } = (e as CustomEvent).detail;
  lastSeen.set(type, payload);
});

export function subscribeWithReplay<K extends EventName>(type: K, handler: Handler<K>) {
  if (lastSeen.has(type)) handler(lastSeen.get(type) as EventPayload<K>);
  return subscribe(type, handler);
}

Now a recommendations panel that mounts a beat late still gets the login it missed. The same coordination problem shows up in navigation — see routing coordination across micro-frontends for the route-event variant.

Bridging the bus across tabs #

Some events must outlive a single tab. When a user logs out in one tab, every other open tab should drop its session too. A BroadcastChannel bridge mirrors a whitelist of events out to other tabs and re-injects inbound ones into the in-page stream — without letting the two transports loop.

// cross-tab-bridge.ts
import { stream$ } from './singleton';
import { BusEvent } from './envelope';

const channel = new BroadcastChannel('acme:bus');
const CROSS_TAB: ReadonlySet<string> = new Set(['user:login', 'theme:set']);

// Outbound: mirror whitelisted local events to other tabs.
stream$.subscribe((e) => {
  if (CROSS_TAB.has(e.type) && !e.crossTab) channel.postMessage(e);
});

// Inbound: replay events from other tabs locally, tagged to prevent re-broadcast.
channel.onmessage = (msg: MessageEvent<BusEvent>) => {
  stream$.next({ ...msg.data, crossTab: true });
};

The crossTab flag is the loop breaker: an event arriving from another tab is re-emitted locally but never bounced back out. Note the constraint — BroadcastChannel runs everything through the structured-clone algorithm, so the same JSON-serializable discipline your Zod schemas enforce is what keeps cross-tab events from throwing.

Edge cases #

The happy path is easy. These are the cases that surface in production.

Teardown and memory leaks #

The single most common bug is a subscription without a matching unsubscribe. In a long session, each unmounted remote leaves a listener that fires against dead state and holds its closure in memory. Enforce the rule mechanically: every subscribe call must own a teardown bound to a lifecycle hook. In code review, a bare subscribe(...) whose return value is discarded is a defect.

Event ordering and storms #

CustomEvent and BroadcastChannel deliver in dispatch order on a single thread, but high-frequency sources — scroll, resize, drag, rapid form input — can flood subscribers and block the main thread. Debounce or throttle at the publisher for these channels, and never put per-keystroke events on the bus when a single committed value will do.

Circular dispatch #

When remote A reacts to an event by publishing one that remote B reacts to by re-publishing the first, you get an infinite loop that freezes the tab. Break it with idempotency: tag events with a source id and ignore your own echoes, or enforce one-way data flow so a subscriber never re-emits the event it just handled.

Serialization boundaries #

If the bus ever crosses a BroadcastChannel, a Web Worker, or postMessage, payloads go through the structured-clone algorithm. Functions, class instances, and DOM nodes won’t survive; circular references throw. Keep payloads to plain JSON-serializable objects — the same constraint your Zod schemas already encourage.

Duplicate buses when the singleton isn’t #

The quietest failure is two buses that each work perfectly — and never talk. It happens when a remote pins a requiredVersion outside the host’s range and Module Federation falls back to its own bundled copy, or when one remote imports @acme/event-bus and another imports a relative ../shared/bus. Both look healthy in isolation; events simply vanish between them. The Symbol.for guard above turns this from a silent void into a console warning, and a startup assertion — log the bus instance’s identity hash from each remote on mount — makes it impossible to miss. Treat a mismatch as a release blocker, not a runtime quirk.

Cross-tab versus in-page semantics #

In-page delivery is synchronous and ordered; cross-tab delivery is asynchronous and unordered relative to local events, because BroadcastChannel schedules a task in each receiving tab. Code that assumes “I published, therefore subscribers have already run” holds in-page but not across tabs. Keep handlers idempotent so a re-ordered or duplicated cross-tab event is harmless, and never round-trip state through another tab when an in-page publish would do — the latency and ordering guarantees are completely different.

Schema-version skew between remotes #

During a rolling deploy, a v1 remote and a v2 remote share the page. A v1 consumer that blindly trusts a v2 payload can read a renamed field as undefined and corrupt its state. The v field on the envelope is the fix: consumers ignore events stamped with a contract version they don’t understand, and producers keep emitting the old shape until every consumer has migrated. This is the runtime half of the additive-only versioning policy in the deployment section.

Testing and validation #

A decoupled bus is testable precisely because it has no direct dependencies.

// bus.test.ts
import { publish, subscribe } from './bus';

test('delivers then stops after teardown', () => {
  const seen: number[] = [];
  const off = subscribe('cart:sync', ({ count }) => seen.push(count));
  publish('cart:sync', { count: 3 });
  off();
  publish('cart:sync', { count: 9 });
  expect(seen).toEqual([3]); // 9 never arrives — listener was removed
});

Deployment #

Because every remote depends on the shared event contract, rolling it out demands discipline.

Common pitfalls #

Issue Root cause & resolution
Events never reach subscribers The bus module isn’t shared, so each remote has its own instance. Fix: set singleton: true and strictVersion: true in the Module Federation shared config and audit the chunk graph for duplicate copies.
Memory grows over a long session Subscriptions created without a matching teardown leak on every unmount. Fix: always bind the returned unsubscribe to a lifecycle hook (useEffect cleanup, ngOnDestroy, onUnmounted); reject bare subscribe calls in review.
Late-mounting remote misses state The event fired before the remote subscribed. Fix: replay the last payload per event with ReplaySubject(1) or a last-seen cache.
Tab freezes under load Circular dispatch or an un-throttled high-frequency channel floods the main thread. Fix: add source-id idempotency to break loops and debounce/throttle scroll, resize, and input events at the publisher.
Older host crashes on a new payload A remote shipped a changed payload shape with no backward compatibility. Fix: validate with Zod and drop invalid payloads, add fields as optional, and keep old event names alive across two release cycles.
Two remotes have separate, silent buses A remote pinned a version outside the host’s range and fell back to its own bundled copy, or imported the bus by a different path. Fix: anchor the instance on Symbol.for(...), log each remote’s bus identity on mount, and treat a mismatch as a release blocker.
Cross-tab events arrive out of order or twice BroadcastChannel is async and per-tab; handlers assumed synchronous in-page ordering. Fix: keep handlers idempotent, tag re-injected events with a crossTab flag to stop loops, and don’t round-trip state through another tab.
Consumer reads a renamed field as undefined Schema-version skew during a rolling deploy: a v1 consumer trusted a v2 payload. Fix: stamp every event with a v field, ignore unknown contract versions, and keep emitting the old shape until all consumers migrate.

FAQ #

Should I use BroadcastChannel, RxJS, or CustomEvent for the bus?

Use CustomEvent for the simplest in-page bus with no dependencies, RxJS when you need operator composition like filter, debounce, and replay, and BroadcastChannel when events must reach other tabs of the same origin. The full trade-off is laid out in BroadcastChannel vs RxJS vs CustomEvent for an event bus.

How do I prevent memory leaks when remotes unmount frequently?

Make every subscription return a teardown function and bind it to the component lifecycle — useEffect cleanup in React, ngOnDestroy in Angular, onUnmounted in Vue. With RxJS, route streams through takeUntil(destroy$). Verify in tests that a publish after teardown reaches no handler.

What happens when a remote emits a payload an older host doesn’t understand?

Runtime validation catches it. The publisher’s safeParse rejects a malformed payload and the host never sees it, so a bad deploy degrades gracefully instead of crashing. Pair this with additive-only schema changes so new fields are simply ignored by older consumers.

Is an event bus a replacement for shared state?

No — they solve different problems. A bus is for notifications between decoupled apps (“the user logged in”), while shared state is for reading the same data across apps. Many systems use both; see synchronizing Redux across micro-frontends for the shared-state side.

How do I evolve the event contract without a coordinated big-bang deploy?

Treat the schema like a public API: additive-only. Stamp every event with a version field, add new fields as optional, and never repurpose an existing field’s meaning. When a breaking shape is unavoidable, introduce a new event name and emit both the old and new for at least two release cycles, then retire the old one once telemetry shows no remaining consumers. Each remote migrates on its own schedule — which is the entire point of keeping them decoupled.