Skip to content

Pub/Sub Pattern and Message Bus

advanced18 min read

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.

Mental Model

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" });
Quiz
What is the fundamental difference between Observer and Pub/Sub?

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" });
Quiz
Why does the EventBus wrap each listener call in a try/catch?

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

CriteriaObserverPub/Sub
CouplingSubject knows observersZero coupling
DebuggingEasy to traceHard to trace
ScaleWithin a moduleAcross modules/bundles
Type safetyNatural (subject types its data)Requires explicit event maps
Use caseReact stores, DOM eventsCross-feature events, analytics, logging
Quiz
A React component needs to sync its state with an external store that lives in a third-party library. Which pattern is more appropriate?

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.

Challenge:

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)
Key Rules
  1. 1Pub/Sub adds a broker between publishers and subscribers — neither side knows the other exists
  2. 2Use Observer for direct subscriptions within a module, Pub/Sub for cross-cutting concerns across module boundaries
  3. 3Always clean up subscriptions — forgotten subscribers cause memory leaks and ghost events
  4. 4Wrap subscriber calls in try/catch to isolate failures — one bad subscriber should never crash the bus
  5. 5Type your event bus with a strict EventMap interface to catch event name typos and payload mismatches at compile time