JavaScript Observer Pattern: Complete Guide

A complete guide to the JavaScript observer pattern. Covers subject and observer implementation, EventEmitter patterns, pub/sub architecture, typed events, wildcard subscriptions, and decoupling components with event-driven design.

JavaScriptadvanced
16 min read

The observer pattern defines a one-to-many relationship between objects: when a subject changes state, all registered observers are notified automatically. This pattern is fundamental to event-driven JavaScript and powers everything from DOM events to reactive frameworks.

For building reactive UIs with observers, see Building a Reactive UI with the JS Observer.

Classic Subject/Observer Implementation

javascriptjavascript
class Subject {
  #observers = new Map();
  #state = {};
 
  subscribe(event, observer) {
    if (!this.#observers.has(event)) {
      this.#observers.set(event, new Set());
    }
    this.#observers.get(event).add(observer);
 
    // Return unsubscribe function
    return () => {
      this.#observers.get(event)?.delete(observer);
    };
  }
 
  notify(event, data) {
    const observers = this.#observers.get(event);
    if (!observers) return;
 
    for (const observer of observers) {
      try {
        observer.update(event, data);
      } catch (error) {
        console.error(`Observer error on "${event}":`, error);
      }
    }
  }
 
  getObserverCount(event) {
    return this.#observers.get(event)?.size || 0;
  }
}
 
class Observer {
  #name;
  #handler;
 
  constructor(name, handler) {
    this.#name = name;
    this.#handler = handler;
  }
 
  update(event, data) {
    this.#handler(event, data, this.#name);
  }
}
 
// Usage
const store = new Subject();
 
const logger = new Observer("Logger", (event, data) => {
  console.log(`[LOG] ${event}:`, data);
});
 
const analytics = new Observer("Analytics", (event, data) => {
  console.log(`[ANALYTICS] Tracking: ${event}`);
});
 
const unsub1 = store.subscribe("user:login", logger);
const unsub2 = store.subscribe("user:login", analytics);
 
store.notify("user:login", { userId: "abc123", timestamp: Date.now() });
 
// Cleanup
unsub1();
console.log(store.getObserverCount("user:login")); // 1

Function-Based EventEmitter

javascriptjavascript
function createEventEmitter() {
  const listeners = new Map();
  const onceListeners = new Map();
  let maxListeners = 10;
 
  function checkMaxListeners(event) {
    const count = (listeners.get(event)?.length || 0) +
      (onceListeners.get(event)?.length || 0);
    if (count > maxListeners) {
      console.warn(
        `Possible memory leak: ${count} listeners for "${event}" (max: ${maxListeners})`
      );
    }
  }
 
  return {
    on(event, handler) {
      if (!listeners.has(event)) listeners.set(event, []);
      listeners.get(event).push(handler);
      checkMaxListeners(event);
      return this;
    },
 
    once(event, handler) {
      if (!onceListeners.has(event)) onceListeners.set(event, []);
      onceListeners.get(event).push(handler);
      return this;
    },
 
    off(event, handler) {
      const handlers = listeners.get(event);
      if (handlers) {
        const idx = handlers.indexOf(handler);
        if (idx > -1) handlers.splice(idx, 1);
      }
      return this;
    },
 
    emit(event, ...args) {
      const handlers = listeners.get(event) || [];
      handlers.forEach((h) => h(...args));
 
      const onceHandlers = onceListeners.get(event) || [];
      onceHandlers.forEach((h) => h(...args));
      onceListeners.delete(event);
 
      return handlers.length + onceHandlers.length > 0;
    },
 
    listenerCount(event) {
      return (listeners.get(event)?.length || 0) +
        (onceListeners.get(event)?.length || 0);
    },
 
    removeAllListeners(event) {
      if (event) {
        listeners.delete(event);
        onceListeners.delete(event);
      } else {
        listeners.clear();
        onceListeners.clear();
      }
      return this;
    },
 
    setMaxListeners(n) {
      maxListeners = n;
      return this;
    },
 
    eventNames() {
      return [...new Set([...listeners.keys(), ...onceListeners.keys()])];
    },
  };
}
 
// Usage with chaining
const emitter = createEventEmitter();
emitter
  .on("data", (chunk) => console.log("Received:", chunk))
  .on("error", (err) => console.error("Error:", err))
  .once("end", () => console.log("Stream ended"));
 
emitter.emit("data", { id: 1, value: "hello" });
emitter.emit("end");
emitter.emit("end"); // No handler fires (was once)

Pub/Sub with Namespaces

javascriptjavascript
class PubSub {
  #channels = new Map();
  #separator = ":";
 
  subscribe(channel, handler) {
    if (!this.#channels.has(channel)) {
      this.#channels.set(channel, new Set());
    }
    this.#channels.get(channel).add(handler);
 
    return {
      unsubscribe: () => {
        this.#channels.get(channel)?.delete(handler);
        if (this.#channels.get(channel)?.size === 0) {
          this.#channels.delete(channel);
        }
      },
    };
  }
 
  subscribePattern(pattern, handler) {
    const regex = new RegExp(
      "^" + pattern.replace(/\*/g, "[^:]+").replace(/#/g, ".*") + "$"
    );
 
    const wrappedHandler = (data, meta) => {
      handler(data, { ...meta, matchedPattern: pattern });
    };
    wrappedHandler._pattern = regex;
    wrappedHandler._original = handler;
 
    if (!this.#channels.has("__patterns__")) {
      this.#channels.set("__patterns__", new Set());
    }
    this.#channels.get("__patterns__").add(wrappedHandler);
 
    return {
      unsubscribe: () => {
        this.#channels.get("__patterns__")?.delete(wrappedHandler);
      },
    };
  }
 
  publish(channel, data) {
    const meta = { channel, timestamp: Date.now() };
    let delivered = 0;
 
    // Exact subscribers
    const handlers = this.#channels.get(channel);
    if (handlers) {
      for (const handler of handlers) {
        handler(data, meta);
        delivered++;
      }
    }
 
    // Pattern subscribers
    const patterns = this.#channels.get("__patterns__");
    if (patterns) {
      for (const handler of patterns) {
        if (handler._pattern.test(channel)) {
          handler(data, meta);
          delivered++;
        }
      }
    }
 
    return delivered;
  }
 
  getChannels() {
    return [...this.#channels.keys()].filter((k) => k !== "__patterns__");
  }
}
 
// Usage
const pubsub = new PubSub();
 
// Exact subscription
pubsub.subscribe("orders:created", (order) => {
  console.log("New order:", order.id);
});
 
// Wildcard: any event in "orders" namespace
pubsub.subscribePattern("orders:*", (data, meta) => {
  console.log(`Orders event [${meta.channel}]:`, data);
});
 
// Deep wildcard: any user event at any depth
pubsub.subscribePattern("users:#", (data, meta) => {
  console.log(`User event [${meta.channel}]:`, data);
});
 
pubsub.publish("orders:created", { id: "ORD-001", total: 59.99 });
pubsub.publish("users:profile:updated", { userId: "U1", name: "Alice" });

Async Observer with Priority

javascriptjavascript
class AsyncEventBus {
  #handlers = new Map();
 
  on(event, handler, priority = 0) {
    if (!this.#handlers.has(event)) {
      this.#handlers.set(event, []);
    }
 
    this.#handlers.get(event).push({ handler, priority });
    this.#handlers
      .get(event)
      .sort((a, b) => b.priority - a.priority);
 
    return () => {
      const list = this.#handlers.get(event);
      const idx = list.findIndex((h) => h.handler === handler);
      if (idx > -1) list.splice(idx, 1);
    };
  }
 
  async emit(event, data) {
    const handlers = this.#handlers.get(event) || [];
    const results = [];
    let stopped = false;
 
    for (const { handler } of handlers) {
      if (stopped) break;
 
      try {
        const result = await handler(data, {
          event,
          stop() {
            stopped = true;
          },
        });
        results.push({ status: "fulfilled", value: result });
      } catch (error) {
        results.push({ status: "rejected", reason: error });
      }
    }
 
    return results;
  }
 
  async emitParallel(event, data) {
    const handlers = this.#handlers.get(event) || [];
    return Promise.allSettled(
      handlers.map(({ handler }) => handler(data, { event }))
    );
  }
}
 
// Usage
const bus = new AsyncEventBus();
 
// High priority: validation runs first
bus.on(
  "order:submit",
  async (order, ctx) => {
    if (!order.items.length) {
      ctx.stop(); // Prevent further handlers
      throw new Error("Empty order");
    }
    return { validated: true };
  },
  100
);
 
// Normal priority: process order
bus.on("order:submit", async (order) => {
  console.log("Processing order:", order.id);
  return { processed: true };
}, 50);
 
// Low priority: send notification
bus.on("order:submit", async (order) => {
  console.log("Sending notification for:", order.id);
}, 10);
 
const results = await bus.emit("order:submit", {
  id: "ORD-002",
  items: [{ sku: "A1", qty: 2 }],
});
console.log(results);
Observer VariantExecution ModelUse CaseComplexity
Classic Subject/ObserverSynchronousSimple state changesLow
EventEmitterSynchronousNode.js-style eventsMedium
Pub/Sub with patternsSynchronousDecoupled microservicesMedium
Async with prioritySequential asyncMiddleware pipelinesHigh
Parallel asyncConcurrent asyncIndependent side effectsMedium
Rune AI

Rune AI

Key Insights

  • Subject/Observer defines a one-to-many notification relationship: Subjects track observers and notify them on state changes, keeping both sides decoupled
  • Return unsubscribe functions to prevent memory leaks: Every subscribe call should return a cleanup function that removes the listener
  • Pub/Sub with pattern matching scales to large applications: Wildcard and namespace-based subscriptions decouple publishers from subscribers entirely
  • Async observers with priority enable middleware-like pipelines: Prioritized sequential execution with stop propagation mirrors express middleware patterns
  • Set maximum listener limits to detect leaked subscriptions: Warning when listener counts exceed a threshold catches forgotten cleanup early
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between observer and pub/sub?

In the classic observer pattern, the subject knows about its observers directly and notifies them. In pub/sub, a mediator (event bus or message broker) sits between publishers and subscribers. Publishers do not know about subscribers. Pub/sub provides looser coupling, making it better for large applications where components should remain independent.

How do I prevent memory leaks with observers?

lways store the unsubscribe function returned by subscribe and call it during cleanup. In browser code, unsubscribe in component unmount handlers or use WeakRef-based observers that can be garbage collected. Set a maximum listener count and warn when it is exceeded, which often indicates forgotten cleanup.

Should I use synchronous or async observers?

Use synchronous observers when handlers are fast and order matters (like updating UI state). Use async observers when handlers perform I/O operations (API calls, database writes, file operations). For independent side effects that do not need ordering, use parallel async emission with Promise.allSettled.

How does the observer pattern relate to JavaScript Promises and async/await?

Promises represent a single future value while observers handle streams of events over time. Event emitters complement Promises: use Promises for request/response patterns and observers for ongoing state changes and notifications. Async iterators (for-await-of) bridge both worlds by turning event streams into iterable sequences.

Can observers cause infinite loops?

Yes. If observer A modifies state that triggers a notification to observer B, and B modifies state that triggers A, you get an infinite loop. Guard against this with a flag that prevents re-entrant notifications, or use a depth counter that throws an error after a maximum recursion depth.

Conclusion

The observer pattern decouples event producers from consumers in JavaScript. The classic subject/observer works for simple state tracking. EventEmitter provides a familiar Node.js-style interface. Pub/sub with namespaces and wildcards scales to large applications. For building reactive user interfaces using these patterns, see Building a Reactive UI with the JS Observer. For understanding the module pattern that often wraps observer implementations, review Implementing the Revealing Module Pattern in JS.