Pub/Sub Pattern and Message Bus
When Observer Isn't Enough
You built an Observer-based notification system. It works great — until your app grows to 50 modules, and now module A needs to react to something module Z does. Module A doesn't import module Z. They don't share a parent. They might not even be in the same bundle. How do they communicate?
// Module A: Auth (loaded on every page)
// Module B: Analytics (lazy-loaded)
// Module C: Notification bell (in the header)
// When a user logs in, all three need to coordinate
// But they can't import each other — they're separate chunks
This is where Pub/Sub enters. Unlike Observer, where the subject directly notifies its observers, Pub/Sub introduces a broker in the middle. Publishers and subscribers never know about each other. They only know about the message bus.
Think of Pub/Sub like a bulletin board in a coffee shop. Anyone can post a note (publish), and anyone can read the board (subscribe). The poster doesn't know who reads their note. The reader doesn't know who posted it. The bulletin board (message bus) is the only shared dependency. Remove any poster or reader, and the system keeps working.
Observer vs. Pub/Sub — The Key Difference
This distinction trips up even experienced developers:
- Observer: Subject directly notifies its observers. The subject holds references to observers. They're coupled — the subject knows observers exist.
- Pub/Sub: Publishers emit events to a broker. Subscribers listen on the broker. Publishers and subscribers have zero knowledge of each other.
// OBSERVER: subject → observers (direct)
class CartSubject {
private observers = new Set<(items: string[]) => void>();
subscribe(fn: (items: string[]) => void) { this.observers.add(fn); }
addItem(item: string) {
this.observers.forEach(fn => fn([item]));
}
}
// PUB/SUB: publisher → broker ← subscriber (indirect)
const bus = new EventBus();
bus.subscribe("cart:item-added", (item) => { /* ... */ });
bus.publish("cart:item-added", { name: "Keyboard" });
Building a Production-Grade Event Bus
Here's a typed event bus with features you'll actually need in production — wildcard subscriptions, once listeners, and error isolation:
type EventMap = Record<string, unknown>;
type Listener<T> = (data: T) => void;
class EventBus<T extends EventMap> {
private channels = new Map<keyof T, Set<Listener<never>>>();
publish<K extends keyof T>(channel: K, data: T[K]): void {
const listeners = this.channels.get(channel);
if (!listeners) return;
listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error(`EventBus error on "${String(channel)}":`, error);
}
});
}
subscribe<K extends keyof T>(channel: K, listener: Listener<T[K]>): () => void {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
const set = this.channels.get(channel)!;
set.add(listener as Listener<never>);
return () => set.delete(listener as Listener<never>);
}
once<K extends keyof T>(channel: K, listener: Listener<T[K]>): () => void {
const unsub = this.subscribe(channel, (data) => {
unsub();
listener(data);
});
return unsub;
}
clear(channel?: keyof T): void {
if (channel) {
this.channels.delete(channel);
} else {
this.channels.clear();
}
}
}
Notice the try/catch around each listener. In Pub/Sub, one bad subscriber should never crash the entire bus. This is a critical difference from Observer, where an exception in one observer typically stops notification of subsequent observers.
interface AppEvents {
"auth:login": { userId: string; role: string };
"auth:logout": { userId: string };
"cart:update": { itemCount: number; total: number };
"analytics:track": { event: string; properties: Record<string, unknown> };
}
const bus = new EventBus<AppEvents>();
const unsubAuth = bus.subscribe("auth:login", ({ userId, role }) => {
console.log(`Welcome, ${userId} (${role})`);
});
const unsubAnalytics = bus.subscribe("auth:login", ({ userId }) => {
bus.publish("analytics:track", {
event: "login",
properties: { userId },
});
});
bus.publish("auth:login", { userId: "u_123", role: "admin" });
Production Scenario: Redux Middleware as Pub/Sub
Redux middleware is a Pub/Sub system in disguise. Every dispatched action is a "published" event. Middleware functions are "subscribers" that can intercept, transform, or react to actions:
import { Middleware } from "redux";
const analyticsMiddleware: Middleware = (store) => (next) => (action) => {
if (action.type === "cart/addItem") {
trackEvent("item_added", {
itemId: action.payload.id,
cartSize: store.getState().cart.items.length + 1,
});
}
return next(action);
};
const loggingMiddleware: Middleware = (store) => (next) => (action) => {
console.log("Dispatching:", action.type);
const result = next(action);
console.log("Next state:", store.getState());
return result;
};
Each middleware subscribes to the action stream without knowing about other middleware. The Redux store is the message bus. Actions are messages. This is why Redux scales to large teams — the auth middleware and the analytics middleware never import each other.
When Pub/Sub Goes Wrong
Pub/Sub's biggest strength is also its biggest weakness: invisible connections. When anything can publish and anything can subscribe, debugging becomes archaeological work.
The Ghost Subscriber Problem
// Component A subscribes but doesn't clean up
function NotificationBell() {
useEffect(() => {
bus.subscribe("notification:new", (data) => {
setCount(prev => prev + 1);
});
// BUG: no cleanup! This listener survives unmount
}, []);
}
Every time NotificationBell mounts and unmounts, a new subscriber is added but never removed. After 10 navigation events, you have 10 ghost subscribers all incrementing the count.
The Event Storm
// Subscriber A publishes an event that triggers Subscriber B,
// which publishes an event that triggers Subscriber A...
bus.subscribe("user:updated", () => {
bus.publish("profile:refresh", {});
});
bus.subscribe("profile:refresh", () => {
bus.publish("user:updated", {}); // Infinite loop!
});
| What developers do | What they should do |
|---|---|
| Using Pub/Sub for communication between parent and child components Pub/Sub is for decoupled, cross-cutting communication. Using it for local parent-child communication hides data flow and makes components harder to understand and test. Props create an explicit, traceable dependency | Use props and callbacks for direct parent-child communication |
| Creating a global event bus as a singleton with no type safety An untyped global bus becomes a dumping ground where anyone publishes anything. Typos in event names become silent bugs. Domain-scoped typed buses limit blast radius and catch errors at compile time | Create typed event buses scoped to specific domains |
| Publishing events synchronously inside render or state updates Publishing during render can trigger subscriber state updates, which triggers re-renders, which publishes again — creating an infinite loop or React concurrent mode violations | Publish events in effects or event handlers, never during render |
When to Use Observer vs. Pub/Sub
| Criteria | Observer | Pub/Sub |
|---|---|---|
| Coupling | Subject knows observers | Zero coupling |
| Debugging | Easy to trace | Hard to trace |
| Scale | Within a module | Across modules/bundles |
| Type safety | Natural (subject types its data) | Requires explicit event maps |
| Use case | React stores, DOM events | Cross-feature events, analytics, logging |
Challenge
Build a ScopedEventBus where subscribers only receive events published within the same scope. This is useful for micro-frontend architectures where each app has its own event namespace.
Try to solve it before peeking at the answer.
// Requirements:
// 1. createScope(name) returns a scoped bus
// 2. Events published in scope A are NOT received by scope B
// 3. A global "broadcast" method sends to ALL scopes
// 4. Each scope has subscribe, publish, and destroy methods
const scopeA = createScope("app-header");
const scopeB = createScope("app-sidebar");
scopeA.subscribe("theme:change", (t) => console.log("Header:", t));
scopeB.subscribe("theme:change", (t) => console.log("Sidebar:", t));
scopeA.publish("theme:change", "dark");
// "Header: dark" (only scope A receives it)
broadcast("theme:change", "light");
// "Header: light"
// "Sidebar: light" (both scopes receive it)- 1Pub/Sub adds a broker between publishers and subscribers — neither side knows the other exists
- 2Use Observer for direct subscriptions within a module, Pub/Sub for cross-cutting concerns across module boundaries
- 3Always clean up subscriptions — forgotten subscribers cause memory leaks and ghost events
- 4Wrap subscriber calls in try/catch to isolate failures — one bad subscriber should never crash the bus
- 5Type your event bus with a strict EventMap interface to catch event name typos and payload mismatches at compile time