Implementing a Global Event Bus with RxJS in Micro-Frontends #

Architectural Context for Distributed UI Communication #

Module Federation isolates JavaScript execution contexts by design, making implicit cross-application communication impossible. Implementing a global event bus with RxJS in micro-frontends establishes a deterministic, observable messaging layer that replaces fragile global singletons and imperative DOM polling. Effective Cross-App State & Context Sharing depends on reactive streams that guarantee delivery semantics, backpressure control, and predictable teardown across independently deployed remote containers.

Precise Problem Statement #

Cross-bundle event routing fails when development teams rely on naive window-attached event emitters or direct module imports across federation boundaries. The isolation enforced by Webpack remotes prevents shared object references, causing events to drop silently or trigger duplicate handlers across isolated heaps. Critical failure modes include:

Root Cause Analysis #

Naive implementations typically instantiate RxJS Subjects inside individual remote bundles. Because each remote maintains its own dependency graph, this creates duplicate Subject instances, runtime version mismatches, and unhandled teardown cycles. As documented in established Event Bus Patterns for Decoupled Apps, imperative event routing lacks the reactive lifecycle hooks required for distributed systems. The core architectural failure is the absence of a centralized, version-agnostic Subject factory and missing synchronization between host and remote component lifecycles. Without strict singleton enforcement, each remote initializes its own RxJS runtime, breaking cross-container observability.

Step-by-Step Resolution #

  1. Isolate the Event Bus: Extract the RxJS event bus into a dedicated shared library or the host container’s entry point to guarantee a single instantiation boundary.
  2. Expose via Module Federation: Export a typed EventBusService through the host’s exposes configuration, ensuring remotes consume the exact same instance via dynamic imports.
  3. Select Stream Primitives: Use BehaviorSubject<T> for stateful cross-app data requiring initial values, and Subject<T> for fire-and-forget signals.
  4. Enforce Teardown Protocols: Mandate explicit subscription management using takeUntil, finalize, and framework-specific lifecycle hooks to prevent memory leaks.
  5. Standardize Payloads: Define strict TypeScript interfaces or Zod schemas for all event payloads to guarantee cross-framework interoperability without runtime type coercion.

Configuration & Code Implementation #

Webpack Module Federation Setup Ensure rxjs is shared as a strict singleton across host and remotes to prevent duplicate runtime instances.

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

module.exports = {
 plugins: [
 new ModuleFederationPlugin({
 name: 'host_app',
 shared: {
 rxjs: {
 singleton: true,
 strictVersion: true,
 requiredVersion: '^7.8.0',
 eager: false
 }
 }
 })
 ]
};

Production-Ready EventBus Implementation

// shared/event-bus.ts
import { Subject, BehaviorSubject, Observable, Subscription, filter, map, takeUntil } from 'rxjs';

export interface EventBusEvent<T = unknown> {
 type: string;
 payload: T;
 timestamp: number;
 source: string;
}

export class EventBusService {
 private readonly _bus = new Subject<EventBusEvent>();
 private readonly _state = new Map<string, BehaviorSubject<unknown>>();
 private readonly _subscriptions = new Set<Subscription>();

 publish<T>(event: Omit<EventBusEvent<T>, 'timestamp' | 'source'>): void {
 const normalized: EventBusEvent<T> = {
 ...event,
 timestamp: Date.now(),
 source: event.source || 'unknown'
 };
 this._bus.next(normalized);
 }

 subscribe<T>(type: string, handler: (payload: T) => void): Subscription {
 const sub = this._bus.pipe(
 filter(e => e.type === type),
 map(e => e.payload as T)
 ).subscribe(handler);

 this._subscriptions.add(sub);
 return sub;
 }

 subscribeUntil<T>(type: string, teardown$: Observable<any>, handler: (payload: T) => void): void {
 this._bus.pipe(
 filter(e => e.type === type),
 map(e => e.payload as T),
 takeUntil(teardown$)
 ).subscribe(handler);
 }

 setState<T>(key: string, value: T): void {
 let subject = this._state.get(key) as BehaviorSubject<T> | undefined;
 if (!subject) {
 subject = new BehaviorSubject<T>(value);
 this._state.set(key, subject);
 } else {
 subject.next(value);
 }
 }

 getState<T>(key: string): Observable<T> {
 return this._state.get(key)?.asObservable() ?? new BehaviorSubject<T>(undefined as T);
 }

 dispose(): void {
 this._subscriptions.forEach(sub => sub.unsubscribe());
 this._subscriptions.clear();
 this._state.forEach(sub => sub.complete());
 this._state.clear();
 this._bus.complete();
 }
}

export const eventBus = new EventBusService();

Framework Bridging (React Consumer)

// react-consumer.tsx
import { useEffect, useRef, useState } from 'react';
import { eventBus } from '@shared/event-bus';

export const RemoteWidget = () => {
 const [data, setData] = useState<string | null>(null);
 const teardownRef = useRef<ReturnType<typeof eventBus.subscribe>>();

 useEffect(() => {
 teardownRef.current = eventBus.subscribe<string>('USER_PROFILE_UPDATE', setData);
 return () => teardownRef.current?.unsubscribe(); // Explicit teardown
 }, []);

 return <div>Profile: {data ?? 'Loading...'}</div>;
};

Validation & Testing Protocol #

Rollback & Fallback Considerations #