Skip to content
JavaScript js patterns 4 min read

Pub/Sub Pattern

The publish/subscribe (pub/sub) pattern lets parts of your application talk to each other without holding direct references. Publishers emit named events; subscribers register interest in those events and react when they fire. A central event bus sits in the middle, so neither side needs to know the other exists. This is one of the most effective ways to keep modules loosely coupled as a codebase grows.

Pub/sub vs the observer pattern

These two patterns are often confused because both broadcast changes to interested listeners. The key difference is who knows about whom.

In the classic observer pattern, the subject keeps a list of its observers and notifies them directly. The subject and observers are aware of each other — observers call subject.subscribe(this). In pub/sub, an intermediary (the event bus / broker) sits between publishers and subscribers. Publishers fire events into the bus and never reference subscribers at all.

AspectObserverPub/Sub
CouplingSubject knows its observersMediated by a bus; sides are unaware of each other
ChannelImplicit (the subject itself)Named topics/events
WiringObservers register on the subjectBoth register on a shared broker
Typical scopeOne object notifying its watchersCross-module / app-wide messaging

Rule of thumb: reach for the observer pattern when one object’s state changes need to notify its own watchers; reach for pub/sub when unrelated modules need to coordinate without importing each other.

Building a tiny event bus

A minimal event bus needs three methods: on to subscribe, off to unsubscribe, and emit to publish. We back it with a Map of event names to Sets of handlers. Using a Set gives us O(1) removal and prevents duplicate registrations of the same function.

class EventBus {
  #listeners = new Map();

  on(event, handler) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(handler);
    // Return an unsubscribe function for convenience.
    return () => this.off(event, handler);
  }

  off(event, handler) {
    this.#listeners.get(event)?.delete(handler);
  }

  emit(event, payload) {
    this.#listeners.get(event)?.forEach((handler) => handler(payload));
  }
}

const bus = new EventBus();

const unsubscribe = bus.on("user:login", (user) => {
  console.log(`Welcome back, ${user.name}`);
});

bus.emit("user:login", { name: "Ada" });
unsubscribe();
bus.emit("user:login", { name: "Grace" }); // no output: already unsubscribed

Output:

Welcome back, Ada

Note that on returns an unsubscribe closure. This is the most ergonomic way to clean up listeners — you store the returned function and call it later, instead of tracking the original handler reference yourself.

A once helper and error isolation

Two refinements make the bus production-ready. A once method auto-removes a handler after its first call, and wrapping each handler invocation protects subscribers from one another — a throwing listener shouldn’t stop the rest from running.

class EventBus {
  #listeners = new Map();

  on(event, handler) {
    (this.#listeners.get(event) ?? this.#set(event)).add(handler);
    return () => this.off(event, handler);
  }

  once(event, handler) {
    const wrapper = (payload) => {
      this.off(event, wrapper);
      handler(payload);
    };
    return this.on(event, wrapper);
  }

  off(event, handler) {
    this.#listeners.get(event)?.delete(handler);
  }

  emit(event, payload) {
    this.#listeners.get(event)?.forEach((handler) => {
      try {
        handler(payload);
      } catch (err) {
        console.error(`Handler for "${event}" threw:`, err);
      }
    });
  }

  #set(event) {
    const set = new Set();
    this.#listeners.set(event, set);
    return set;
  }
}

Use cases for cross-module communication

Pub/sub shines when otherwise-unrelated parts of an app need to react to the same fact. A common example: when a user logs in, the analytics module logs an event, the UI updates a greeting, and a cache module preloads data — none of which should know about each other.

// auth.js — the publisher
import { bus } from "./bus.js";

export async function login(credentials) {
  const user = await api.authenticate(credentials);
  bus.emit("user:login", user);
  return user;
}

// analytics.js — a subscriber
import { bus } from "./bus.js";
bus.on("user:login", (user) => track("login", { id: user.id }));

// header.js — another subscriber
import { bus } from "./bus.js";
bus.on("user:login", (user) => renderGreeting(user.name));

The auth module fires one event; adding a new reaction later means adding one subscriber file, with zero edits to auth.js. That open-for-extension property is the real payoff.

You also get pub/sub for free in several runtimes. The browser’s EventTarget/CustomEvent and Node’s built-in EventEmitter are both pub/sub buses you can use instead of rolling your own.

// Node — built-in EventEmitter
import { EventEmitter } from "node:events";
const bus = new EventEmitter();
bus.on("order:placed", (order) => console.log(`Order ${order.id}`));
bus.emit("order:placed", { id: 42 });

Output:

Order 42

Gotcha: forgetting to call off is the most common cause of memory leaks with long-lived buses. A subscriber that closes over a DOM node or component keeps that object alive forever. Always unsubscribe when a component unmounts.

Best Practices

  • Namespace events with a domain:action convention (e.g. user:login, cart:updated) to avoid collisions and keep intent readable.
  • Return an unsubscribe function from on so cleanup is a single call rather than re-passing the original handler.
  • Wrap handler invocations in try/catch so one faulty subscriber can’t break the rest of the dispatch.
  • Always unsubscribe long-lived listeners on teardown to prevent memory leaks.
  • Keep payloads as plain serializable data; don’t pass mutable shared state that subscribers might alter unexpectedly.
  • Prefer the platform’s EventTarget or Node EventEmitter for standard cases instead of reinventing a bus.
  • Don’t overuse it — indirection makes control flow harder to trace, so reserve pub/sub for genuinely decoupled cross-module communication.
Last updated June 1, 2026
Was this helpful?