Implementing a Global Event Bus with RxJS in Micro-Frontends #
You need independently deployed remote apps to publish and react to events without importing each other, and RxJS gives you a typed, observable message channel that does exactly that — provided every remote shares the same bus instance.
This guide walks through a working implementation: a typed Subject-based bus, exposed once through Module Federation as a singleton, with strongly typed events, filtered subscriptions, and leak-free teardown using takeUntil. It builds on the comparison of event bus patterns for decoupled apps; if you are still deciding between transport mechanisms, start with BroadcastChannel vs RxJS vs CustomEvent for an event bus first.
Prerequisites #
Before you start, confirm the following are in place:
- RxJS 7.8 or later installed in the host and every remote (
npm install rxjs@^7.8.0). The pipeable operator and tree-shaking behavior below assume RxJS 7.x. - Webpack 5 Module Federation (or the Vite federation plugin) already wired between a host and at least one remote.
- TypeScript 5.x for the typed event contract. Plain JavaScript works, but you lose the compile-time safety that makes a shared bus maintainable.
- A single owning package for the bus — either a small shared library or the host’s entry. The bus must be instantiated exactly once; the whole pattern collapses if each remote creates its own.
The single hardest requirement is the last one. Two remotes that each instantiate a Subject are two separate buses that never see each other’s events. The diagram below shows the difference between the broken and correct topologies.
Step 1 — Define a typed event contract #
Start with a discriminated union so every publish and subscribe call is type-checked. Put this in the shared package alongside the bus.
// shared/event-bus/events.ts
export interface UserProfileUpdated {
type: 'user/profile-updated';
payload: { userId: string; displayName: string };
}
export interface CartItemAdded {
type: 'cart/item-added';
payload: { sku: string; quantity: number };
}
// The full set of events any remote may emit or consume.
export type AppEvent = UserProfileUpdated | CartItemAdded;
export type EventType = AppEvent['type'];
// Narrow an AppEvent down to one variant by its `type`.
export type EventOf<K extends EventType> = Extract<AppEvent, { type: K }>;
The discriminated union lets TypeScript infer the exact payload shape from the type string later, so consumers never cast.
Step 2 — Build the Subject-based bus #
The bus is a thin wrapper over a single RxJS Subject. It exposes a typed publish and a typed on that filters by event type and narrows the payload.
// shared/event-bus/bus.ts
import { Subject, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
import type { AppEvent, EventType, EventOf } from './events';
export class EventBus {
private readonly stream$ = new Subject<AppEvent>();
/** Emit an event to every subscriber. */
publish(event: AppEvent): void {
this.stream$.next(event);
}
/** Stream of events narrowed to a single type. */
on<K extends EventType>(type: K): Observable<EventOf<K>> {
return this.stream$.pipe(
filter((e): e is EventOf<K> => e.type === type),
);
}
/** Complete the underlying Subject — call only on full app teardown. */
destroy(): void {
this.stream$.complete();
}
}
on('cart/item-added') returns Observable<CartItemAdded> — the filter type guard narrows the union, so subscribers get payload.sku with no casting.
Two extra methods earn their place once real consumers appear. A onAny accessor exposes the raw stream for logging and telemetry, and an onTypes helper lets a consumer react to several related events without stacking subscriptions. Both stay fully typed.
// shared/event-bus/bus.ts (additions inside the class)
import { Subject, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';
import type { AppEvent, EventType, EventOf } from './events';
/** The whole stream — use for logging, devtools, and telemetry only. */
onAny(): Observable<AppEvent> {
return this.stream$.asObservable();
}
/** React to any of several event types in one subscription. */
onTypes<K extends EventType>(types: readonly K[]): Observable<EventOf<K>> {
const set = new Set<EventType>(types);
return this.stream$.pipe(
filter((e): e is EventOf<K> => set.has(e.type)),
);
}
asObservable() matters: it hands callers a read-only view so a remote can never reach in and call next() on the shared Subject directly. Publishing stays funnelled through the typed publish method, which keeps the event contract enforceable and the audit log in onAny complete.
Step 3 — Expose the bus as a Module Federation singleton #
The bus must resolve to one instance regardless of which remote loads it first. Export a module that lazily creates the instance, then share that module as a singleton.
// shared/event-bus/index.ts
import { EventBus } from './bus';
// Module-scoped instance: one per JS module evaluation.
export const eventBus = new EventBus();
export type { AppEvent, EventType, EventOf } from './events';
Mark both the shared package and rxjs as singletons in every host and remote config. This guarantees Module Federation hands back the already-evaluated module instead of re-evaluating it per remote.
// webpack.config.js — host AND every remote (identical block)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host', // unique per app
shared: {
rxjs: { singleton: true, strictVersion: true, requiredVersion: '^7.8.0' },
'@shared/event-bus': { singleton: true, strictVersion: false },
},
}),
],
};
singleton: true is what makes eventBus the same object across the federation. For the deeper mechanics of how the runtime picks one shared copy, see managing shared dependencies at runtime.
If any remote is built with Vite, mirror the same singleton intent in @originjs/vite-plugin-federation so the two toolchains agree on one shared copy. The shape differs but the contract is identical.
// vite.config.ts — a Vite-built remote
import federation from '@originjs/vite-plugin-federation';
export default {
plugins: [
federation({
name: 'remote-cart',
shared: {
rxjs: { singleton: true, requiredVersion: '^7.8.0' },
'@shared/event-bus': { singleton: true },
},
}),
],
};
One caveat that breaks the singleton even when the config is correct: importing the bus into a module that the federation runtime treats as eager. If @shared/event-bus is pulled in by the synchronous entry chunk before the shared scope initializes, Webpack falls back to a private bundled copy. Keep the first import of the bus behind the same async boundary as your remote mounts (a dynamic import() or React.lazy), and the runtime will resolve the shared instance every time.
Step 4 — Publish from a remote #
Any remote imports the shared instance and calls publish. No direct import of another remote is needed.
// remote-cart/add-to-cart.ts
import { eventBus } from '@shared/event-bus';
export function addToCart(sku: string, quantity: number): void {
// ...local cart mutation...
eventBus.publish({ type: 'cart/item-added', payload: { sku, quantity } });
}
Step 5 — Subscribe with takeUntil for safe teardown #
Every subscription must be torn down when the consuming component unmounts. Drive that with a takeUntil(destroy$) gate rather than manually tracking Subscription objects.
// remote-header/CartBadge.tsx
import { useEffect, useState } from 'react';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { eventBus } from '@shared/event-bus';
export function CartBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
const destroy$ = new Subject<void>();
eventBus
.on('cart/item-added')
.pipe(takeUntil(destroy$))
.subscribe((e) => setCount((c) => c + e.payload.quantity));
return () => {
destroy$.next();
destroy$.complete();
};
}, []);
return <span aria-label="cart count">{count}</span>;
}
takeUntil(destroy$) completes the inner stream the moment destroy$ emits, so the subscription is released even if other operators are chained after it. This one habit eliminates the most common class of bus leaks.
Verification #
Confirm the bus behaves as a single shared instance and tears down cleanly:
- One RxJS, one bus. Run
npm ls rxjsat the workspace root — it must list a single resolved version. In the browser, setwindow.__bus = eventBusfrom two different remotes and assertremoteA.__bus === remoteB.__busin the console. - Events cross boundaries. Trigger
addToCartin the cart remote and watch the header remote’s badge increment. Add a temporaryeventBus.on('cart/item-added').subscribe(console.log)in the host and confirm one log line per publish — not zero, not two. - No leak after unmount. In Chrome DevTools, take a heap snapshot, mount and unmount the subscribing component ten times, force GC, and snapshot again. Detached subscriber closures should not accumulate; retained
Subscribercount should return to baseline. - Unit-test the contract. Assert the filter narrows correctly:
// bus.test.ts
import { EventBus } from './bus';
test('on() delivers only matching events', () => {
const bus = new EventBus();
const seen: string[] = [];
bus.on('cart/item-added').subscribe((e) => seen.push(e.payload.sku));
bus.publish({ type: 'user/profile-updated', payload: { userId: '1', displayName: 'A' } });
bus.publish({ type: 'cart/item-added', payload: { sku: 'X1', quantity: 2 } });
expect(seen).toEqual(['X1']);
});
Troubleshooting #
Symptom: events publish but no remote ever receives them.
Diagnosis: you have two bus instances because the shared module is not actually a singleton. This happens when one app omits the shared entry, when requiredVersion ranges do not overlap so the runtime keeps separate copies, or when a remote bundles the bus eagerly instead of consuming the shared one. Fix: add the identical @shared/event-bus and rxjs singleton: true block to every host and remote, then verify with the remoteA.__bus === remoteB.__bus check above. If versions diverge, align them or set strictVersion: false on the bus package so the runtime reuses the loaded copy.
Symptom: handlers keep firing after a component is gone, or memory grows on navigation.
Diagnosis: a subscription was created without a teardown path — the classic missing-unsubscribe leak. A bare eventBus.on(...).subscribe(...) in a useEffect with no cleanup keeps the Subscriber alive forever, and each remount adds another. Fix: gate every subscription with takeUntil(destroy$) and emit destroy$ in the cleanup function, as in Step 5. For non-React consumers, store the Subscription and call unsubscribe() in the framework’s destroy hook.
Symptom: a remote that mounts late misses events that were already published.
Diagnosis: a plain Subject is hot and has no buffer — it only delivers to subscribers present at emit time. A header that loads after the cart remote already fired cart/item-added will show a stale count. Fix: for state that latecomers must observe, replace Subject with BehaviorSubject (last value, ideal for “current count” semantics) or ReplaySubject(n) (last n events). Use a plain Subject only for true fire-and-forget signals where missing a past event is acceptable. Do not switch everything to ReplaySubject indiscriminately — an unbounded replay buffer is itself a slow leak.
Symptom: subscribers see events in an order that produces inconsistent UI.
Diagnosis: Subject.next() is synchronous and re-entrant. If a handler publishes another event while reacting to the first, the second event is delivered to all subscribers before the first publish finishes unwinding, interleaving updates. Fix: avoid publishing from inside a handler; if you must, defer it with queueMicrotask or pipe the emitting stream through observeOn(asapScheduler) so re-entrant publishes are flushed after the current dispatch completes.