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.
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.