immutable data carriers. Using a base class with a static type identifier ensures uniqueness even after code minification and provides compile-time safety.
// events/TypedEvent.ts
export abstract class TypedEvent<TPayload> {
public abstract readonly type: string;
public readonly timestamp: number = Date.now();
constructor(public readonly payload: TPayload) {}
}
// events/TaskCompletedEvent.ts
import { TypedEvent } from './TypedEvent';
export interface TaskCompletedPayload {
taskId: string;
durationMs: number;
status: 'success' | 'failure';
}
export class TaskCompletedEvent extends TypedEvent<TaskCompletedPayload> {
public readonly type = 'TASK_COMPLETED_V1';
constructor(payload: TaskCompletedPayload) {
super(payload);
}
}
Rationale:
- Static Type Identifier: The
type string serves as a unique key. Versioning (e.g., V1) allows for backward-compatible evolution of payloads.
- Immutability: The payload is
readonly, preventing subscribers from mutating shared data, which is a common source of subtle bugs.
- Timestamp: Including metadata like timestamps aids in debugging and analytics without polluting the business payload.
Step 2: Implement the Message Broker
The broker acts as the central hub. It should support publishing, subscribing, and scoped lifecycle management.
// core/MessageBroker.ts
import { TypedEvent } from '../events/TypedEvent';
type EventHandler<TPayload> = (event: TypedEvent<TPayload>) => void;
type UnsubscribeFn = () => void;
export class MessageBroker {
private listeners: Map<string, Set<EventHandler<any>>> = new Map();
private scopes: Map<string, Set<UnsubscribeFn>> = new Map();
public publish<TPayload>(event: TypedEvent<TPayload>): void {
const handlers = this.listeners.get(event.type);
if (!handlers) return;
// Fire-and-forget: Execute synchronously, ignore return values
handlers.forEach(handler => {
try {
handler(event);
} catch (error) {
// In production, route to error monitoring service
console.error(`Handler error for ${event.type}:`, error);
}
});
}
public subscribe<TPayload>(
eventType: string,
handler: EventHandler<TPayload>,
scopeId?: string
): UnsubscribeFn {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(handler);
const unsubscribe = () => {
this.listeners.get(eventType)?.delete(handler);
if (scopeId) {
this.scopes.get(scopeId)?.delete(unsubscribe);
}
};
if (scopeId) {
if (!this.scopes.has(scopeId)) {
this.scopes.set(scopeId, new Set());
}
this.scopes.get(scopeId)!.add(unsubscribe);
}
return unsubscribe;
}
public disposeScope(scopeId: string): void {
const unsubscribers = this.scopes.get(scopeId);
if (unsubscribers) {
unsubscribers.forEach(unsub => unsub());
this.scopes.delete(scopeId);
}
}
}
Rationale:
- Synchronous Execution: Fire-and-forget implies the publisher does not wait. Handlers execute immediately.
- Error Isolation: The
try/catch block ensures that a failure in one subscriber does not prevent other subscribers from receiving the event or crash the publisher.
- Scoped Cleanup: The
scopeId parameter allows grouping subscriptions. Calling disposeScope removes all listeners associated with that scope, eliminating manual unsubscription boilerplate.
Step 3: Usage in Components
Publisher:
The component triggers the event and moves on. It has zero knowledge of who receives it.
// components/TaskRunner.ts
import { MessageBroker } from '../core/MessageBroker';
import { TaskCompletedEvent } from '../events/TaskCompletedEvent';
export class TaskRunner {
constructor(private broker: MessageBroker) {}
async runTask(taskId: string): Promise<void> {
const start = performance.now();
try {
// ... task logic ...
this.broker.publish(new TaskCompletedEvent({
taskId,
durationMs: performance.now() - start,
status: 'success'
}));
} catch (e) {
this.broker.publish(new TaskCompletedEvent({
taskId,
durationMs: performance.now() - start,
status: 'failure'
}));
}
}
}
Subscriber with Lifecycle Management:
Subscribers register within a scope. When the scope is disposed, all subscriptions are cleaned up automatically.
// components/AnalyticsDashboard.ts
import { MessageBroker } from '../core/MessageBroker';
import { TaskCompletedEvent } from '../events/TaskCompletedEvent';
export class AnalyticsDashboard {
private scopeId = 'analytics-dashboard';
constructor(private broker: MessageBroker) {
this.broker.subscribe(
TaskCompletedEvent.prototype.type,
(event) => this.logTask(event.payload),
this.scopeId
);
}
private logTask(payload: TaskCompletedEvent['payload']): void {
// Send to analytics provider
console.log(`[Analytics] Task ${payload.taskId} finished in ${payload.durationMs}ms`);
}
public destroy(): void {
// Automatically unsubscribes all handlers in this scope
this.broker.disposeScope(this.scopeId);
}
}
Pitfall Guide
Adopting an event bus introduces new failure modes. Below are common mistakes and how to avoid them.
1. Using Events for State Synchronization
Mistake: Firing events to update a value that multiple components must display (e.g., UserUpdatedEvent).
Explanation: Events are for actions, not state. If a component mounts after the event fires, it misses the update. This leads to inconsistent UI.
Fix: Use a reactive store (e.g., Redux, Signals, MobX) for state. Events can trigger state changes, but the state itself should be the source of truth.
2. High-Frequency Event Flooding
Mistake: Firing events on every mouse move or scroll tick.
Explanation: The event bus adds overhead per message. High-frequency events can saturate the main thread and degrade performance.
Fix: Use native DOM events or framework-specific listeners for high-frequency interactions. If using the bus, implement throttling or debouncing at the publisher level.
3. Implicit Contract Drift
Mistake: Changing the event payload structure without updating all subscribers.
Explanation: Since publishers and subscribers are decoupled, a payload change can break subscribers silently at runtime.
Fix: Enforce strict TypeScript interfaces. Implement event versioning in the type string. Add runtime validation in critical subscribers during development.
4. Circular Dependencies
Mistake: Component A fires Event X, which triggers Component B to fire Event Y, which triggers Component A again.
Explanation: This creates an infinite loop or stack overflow.
Fix: Review event flows during design. If bidirectional communication is needed, consider a request-response pattern or a shared state model instead.
5. The Silent Failure Problem
Mistake: A subscriber throws an error, but the publisher never knows.
Explanation: Fire-and-forget means the publisher assumes success. If a critical subscriber fails, the system may be in an inconsistent state.
Fix: Implement a global error handler in the broker. For critical paths, consider a "fire-and-verify" pattern where the publisher checks a result state after a timeout.
6. Over-Abstraction for Simple Cases
Mistake: Using the event bus for parent-child component communication.
Explanation: Introducing a bus for simple hierarchies adds unnecessary complexity and indirection.
Fix: Use direct props and callbacks for parent-child relationships. Reserve the event bus for cross-module or distant component communication.
7. Memory Leaks via Closures
Mistake: Subscribers capture references to large objects or DOM nodes in closures, preventing garbage collection even after unsubscription.
Explanation: The handler function retains the closure scope. If the scope holds heavy data, memory is leaked.
Fix: Keep subscriber handlers lean. Access data via services or stores rather than capturing large objects in the closure.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Parent-Child Update | Props / Callbacks | Direct link is simpler and faster. | Low |
| Cross-Module Notification | Event Bus | Decouples modules; enables dynamic subscribers. | Medium |
| Global State Sync | Reactive Store | Provides consistent state for late subscribers. | Medium |
| High-Frequency Input | Native Events | Lower overhead; framework optimized. | Low |
| Request-Response | Async Service / RPC | Requires return value and error handling. | High |
Configuration Template
Use this template to standardize event definitions across your team.
// events/BaseEvent.ts
export abstract class BaseEvent<TPayload> {
public abstract readonly type: string;
public readonly id: string = crypto.randomUUID();
public readonly timestamp: number = Date.now();
constructor(public readonly payload: TPayload) {}
}
// events/EventRegistry.ts
export const EVENT_TYPES = {
TASK_COMPLETED: 'TASK_COMPLETED_V1',
USER_LOGGED_IN: 'USER_LOGGED_IN_V1',
// Add new events here to maintain a central registry
} as const;
// events/TaskCompletedEvent.ts
import { BaseEvent } from './BaseEvent';
import { EVENT_TYPES } from './EventRegistry';
export interface TaskCompletedPayload {
taskId: string;
durationMs: number;
}
export class TaskCompletedEvent extends BaseEvent<TaskCompletedPayload> {
public readonly type = EVENT_TYPES.TASK_COMPLETED;
}
Quick Start Guide
- Initialize the Broker: Create a singleton instance of
MessageBroker and inject it into your application root.
- Define an Event: Create a new class extending
TypedEvent with a unique type string and payload interface.
- Publish: Call
broker.publish(new YourEvent(payload)) from any component or service.
- Subscribe: Call
broker.subscribe(YourEvent.type, handler, scopeId) in your consumer. Ensure the scope is disposed when the component unmounts.
- Test: Mock the broker in unit tests. Verify
publish calls in publishers and trigger events manually to test subscriber logic.