The Observer Pattern in JavaScript

The Observer Pattern (also called Publish/Subscribe or Pub/Sub) is one of the most widely used design patterns in JavaScript. At its core, it defines a one-to-many dependency: when one object (the subject) changes state, all its observers are automatically notified. You use this pattern every time you call addEventListener — and understanding it will help you build decoupled, event-driven architectures.

The Problem It Solves

Imagine a shopping cart that needs to update the header badge count, the checkout sidebar, and a local storage cache whenever an item is added. Without a pattern, you'd tightly couple all three pieces of logic together:

function addItem(item) {
  cart.push(item);
  updateHeaderBadge();    // tightly coupled
  updateSidebar();         // tightly coupled
  saveToLocalStorage();    // tightly coupled
}

Every time you add a new feature, you modify addItem. The Observer pattern inverts this: components subscribe to events they care about, and the subject simply publishes — no knowledge of who's listening required.

Building an EventEmitter from Scratch

class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(event, listener) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(listener);
    return this; // Enable chaining
  }

  off(event, listener) {
    if (!this.events[event]) return this;
    this.events[event] = this.events[event].filter(l => l !== listener);
    return this;
  }

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

  emit(event, ...args) {
    if (!this.events[event]) return false;
    this.events[event].forEach(listener => listener(...args));
    return true;
  }
}

Using the EventEmitter

const cart = new EventEmitter();

// Subscribe — each function is completely independent
cart.on("item:added", (item) => {
  console.log(`Header: Cart now has items (+${item.name})`);
});

cart.on("item:added", (item) => {
  console.log(`Sidebar: ${item.name} — $${item.price}`);
});

cart.once("item:added", () => {
  console.log("Welcome! First item added — showing promo.");
});

// Publish — no knowledge of subscribers needed
function addToCart(item) {
  // ... actual cart logic ...
  cart.emit("item:added", item);
}

addToCart({ name: "Laptop Stand", price: 49.99 });
// Header: Cart now has items (+Laptop Stand)
// Sidebar: Laptop Stand — $49.99
// Welcome! First item added — showing promo.

addToCart({ name: "USB Hub", price: 29.99 });
// Header and Sidebar fire again, but NOT the once() handler

Key Methods Explained

MethodDescription
on(event, fn)Subscribe a listener to an event
off(event, fn)Unsubscribe a specific listener
once(event, fn)Subscribe a listener that fires only once, then removes itself
emit(event, ...args)Publish an event with optional data payload

Real-World Applications

  • DOM events: The browser's built-in addEventListener is the Observer pattern.
  • Node.js EventEmitter: The backbone of streams, HTTP servers, and most core Node modules.
  • State management: Redux's store notifies subscribed components of state changes.
  • WebSockets: Real-time messages are pushed to all subscribed handlers.

Pitfalls to Watch Out For

  1. Memory leaks: Always call off() to remove listeners when a component is destroyed.
  2. Event name collisions: Use namespaced event names like "cart:item:added" in large applications.
  3. Debugging complexity: Events that trigger other events can create hard-to-trace chains. Keep event logic simple.

Conclusion

The Observer pattern is fundamental to event-driven JavaScript. Building your own EventEmitter, even if you end up using a library's implementation, gives you a clear mental model of how frameworks like React, Vue, and Node.js work under the hood. It's one of the most valuable patterns to have in your toolkit.