BroadcastChannel vs RxJS vs CustomEvent for an Event Bus #

When independently deployed remotes need to talk to each other, you have to pick a transport for the messages before you write a single handler — and the three obvious candidates have wildly different scope, dependency cost, and failure modes.

This guide compares BroadcastChannel, an RxJS Subject, and a CustomEvent dispatched on a DOM target as the wire for a micro-frontend event bus. It is a decision guide, not a tutorial: if you already know you want reactive streams, jump to the full walkthrough in Implementing a Global Event Bus with RxJS in Micro-Frontends. For the broader set of messaging shapes, see the parent guide on Event Bus Patterns for Decoupled Apps.

The decision in one sentence #

Pick by scope first: if events must cross browser tabs, you need BroadcastChannel; if they stay in one page and you want operators, reach for RxJS; if you want zero dependencies and DOM-native semantics, use CustomEvent.

Everything else — backpressure, replay, payload constraints, teardown — is a tiebreaker once scope has narrowed the field.

Three event bus transport topologies BroadcastChannel spans multiple tabs of one origin; an RxJS Subject and a CustomEvent both stay within a single page. BroadcastChannel cross-tab, same origin Tab A Tab B "mfe-bus" channel RxJS Subject in-page, operators publish next() filter / map / scan subscribe() CustomEvent in-page, zero-dep DOM target dispatch listen
Only BroadcastChannel crosses tab boundaries; RxJS and CustomEvent live inside a single document.

The comparison table #

Dimension BroadcastChannel RxJS Subject CustomEvent on DOM target
Scope Cross-tab + in-page, same origin In-page only (one JS realm) In-page only (one document)
Dependencies None (Web API) rxjs (~30–40 KB, shared as singleton) None (Web API)
Operators / transforms None — raw messages Rich (filter, map, debounceTime, scan) None — raw events
Backpressure None (fire-and-forget queue) Via operators + schedulers None
Replay / late subscribers No replay; late tabs miss events BehaviorSubject / ReplaySubject No replay
Payload constraints Structured-clone only (no functions/DOM) Any JS value, including class instances Any JS value in detail (same realm)
Serialization Forced (structured clone) None (same reference passed) None (same reference passed)
Ordering FIFO per channel Synchronous, deterministic Synchronous, deterministic
Teardown / memory channel.close() unsubscribe() / takeUntil removeEventListener
Framework-agnostic Yes Yes (instance, not framework) Yes
Developer experience Minimal API, easy to start Powerful but learning curve Familiar to all DOM devs

The single sharpest divide is the scope row. BroadcastChannel is the only option that survives a second tab; the other two die at the realm boundary. The second sharpest is payload constraints: BroadcastChannel forces a structured-clone copy, so functions, class instances, and DOM nodes silently fail, while the in-page options pass references untouched.

A minimal example of each #

These are deliberately tiny but real. Each exposes a publish and a subscribe, so the call sites stay identical regardless of transport — which is the whole point if you later want to swap one for another.

BroadcastChannel #

// bus-broadcast.js — survives across every same-origin tab
const channel = new BroadcastChannel('mfe-bus');

export function publish(type, payload) {
  // payload must be structured-cloneable: no functions, no DOM nodes
  channel.postMessage({ type, payload });
}

export function subscribe(type, handler) {
  const onMessage = (event) => {
    if (event.data?.type === type) handler(event.data.payload);
  };
  channel.addEventListener('message', onMessage);
  return () => channel.removeEventListener('message', onMessage);
}

Note the publisher’s own tab does not receive its own message — BroadcastChannel only delivers to other contexts. If a remote needs to react to events it also emits, call the handler locally too.

RxJS Subject #

// bus-rxjs.ts — in-page, with operators and replay options
import { Subject, filter, map } from 'rxjs';

interface BusEvent<T = unknown> { type: string; payload: T; }

const subject = new Subject<BusEvent>();

export function publish<T>(type: string, payload: T) {
  subject.next({ type, payload });
}

export function subscribe<T>(type: string, handler: (payload: T) => void) {
  const sub = subject
    .pipe(
      filter((e) => e.type === type),
      map((e) => e.payload as T),
    )
    .subscribe(handler);
  return () => sub.unsubscribe();
}

This module must be a singleton across remotes, or each bundle gets its own Subject and nobody hears anyone. In Module Federation that means sharing the bus module — and rxjs — with singleton: true, which is exactly the concern covered in Managing Shared Dependencies at Runtime.

CustomEvent on a DOM target #

// bus-customevent.js — zero-dependency, in-page
const target = new EventTarget(); // or window, if you accept the global

export function publish(type, payload) {
  target.dispatchEvent(new CustomEvent(`mfe:${type}`, { detail: payload }));
}

export function subscribe(type, handler) {
  const onEvent = (event) => handler(event.detail);
  target.addEventListener(`mfe:${type}`, onEvent);
  return () => target.removeEventListener(`mfe:${type}`, onEvent);
}

Using a private EventTarget instead of window keeps your event names out of the global namespace and avoids collisions with library events. Like the RxJS option, the EventTarget instance must be shared as a singleton for cross-remote delivery.

Which to pick, by scenario #

You need cross-tab sync (logout everywhere, theme change, cart updates across tabs). Use BroadcastChannel. It is the only transport here that reaches other tabs without a server round-trip. Pair it with an in-page transport if you also want same-tab delivery, since a tab does not receive its own broadcasts.

You have complex event flows: debouncing, combining streams, derived state, retries. Use RxJS. The operator library pays for its bundle weight the moment you need debounceTime, combineLatest, or scan. You also get BehaviorSubject/ReplaySubject for late subscribers — critical when a remote loads after an event has already fired.

You want zero dependencies, simple fan-out, and a team that already knows the DOM. Use CustomEvent. It is the lightest option, has no learning curve, and is trivial to debug in DevTools. It is a fine default for low-volume signals like “remote-ready” or “user-updated”.

You are mid-spectrum and unsure. Start with CustomEvent behind the publish/subscribe facade above. If you later need operators, swap the internals for RxJS without touching call sites; if you need cross-tab, add a BroadcastChannel bridge inside the same facade. The facade is what makes Cross-App State & Context Sharing transport-agnostic.

Gotchas #

BroadcastChannel: structured clone is unforgiving. Posting an object with a function property, a Map of DOM nodes, or a class instance you expect to keep its prototype will either throw or arrive as a plain object. Send only plain data and reconstruct on the other side.

BroadcastChannel: no self-delivery, no replay. A tab opened after an event fired never sees it, and the emitting tab never hears its own message. For “current state” semantics, persist to localStorage and read on init, then use the channel for live deltas.

RxJS: duplicate Subjects are the classic micro-frontend bug. If two remotes each instantiate the bus because the module was not shared as a singleton, publishes vanish. Verify a single instance the same way you would for any shared library — see Implementing a Global Event Bus with RxJS in Micro-Frontends.

RxJS: leaked subscriptions outlive remotes. When a remote unmounts, every subscribe() it opened must be torn down with unsubscribe() or takeUntil(destroy$), or you accumulate handlers on a long-lived Subject.

CustomEvent: window is a shared bus you don’t own. Dispatching on window means other libraries and the browser can fire same-named events. Namespace your event types (mfe:*) and prefer a private EventTarget.

All three: synchronous handlers can deadlock your render. RxJS and CustomEvent deliver synchronously on the same tick; a slow handler blocks the publisher. For heavy work, defer with queueMicrotask or an RxJS observeOn scheduler.

FAQ #

Can I use BroadcastChannel for same-tab communication between remotes?

You can, but it is the wrong tool. A tab does not receive its own broadcasts, so same-tab delivery requires a separate in-page path anyway. Use RxJS or CustomEvent in-page and reserve BroadcastChannel for the cross-tab hop.

Is RxJS overkill if I only emit a handful of events?

Often, yes. The 30–40 KB and the operator learning curve are only worth it when you actually need transforms, replay, or stream composition. For simple fan-out, CustomEvent gets you there with no dependency.

How do I keep call sites stable if I might change transports later?

Wrap whichever transport you choose behind a publish(type, payload) / subscribe(type, handler) facade, as in the examples above. Call sites only see those two functions, so swapping the implementation never touches consuming remotes.