Skip to content
Node.js nd patterns 4 min read

The Observer Pattern

The observer pattern defines a one-to-many dependency between objects so that when one object (the subject) changes state, all of its dependents (the observers) are notified automatically. It decouples the thing producing events from the things reacting to them, which is exactly why it sits at the heart of Node.js — almost everything event-driven, from HTTP servers to streams, is built on it. Understanding the pattern lets you model reactive behavior cleanly instead of hard-wiring callers to callees.

Subjects and observers

In classic observer terminology there are two roles. A subject maintains a list of observers and exposes methods to subscribe, unsubscribe, and notify. An observer exposes an update method (or simply a callback) that the subject invokes when something changes. The subject knows that observers exist but not who they are or what they do, so you can add or remove reactions without touching the producer.

This inversion is what makes the pattern valuable: the subject broadcasts, and any number of independent observers respond on their own terms.

EventEmitter: Node’s built-in observer

You rarely need to hand-roll the pattern in Node because EventEmitter from the node:events module is a battle-tested implementation. The emitter is the subject, on() registers an observer, and emit() is the notify step. Listeners receive any arguments passed to emit.

import { EventEmitter } from "node:events";

class OrderService extends EventEmitter {
  placeOrder(order) {
    // ... persist the order ...
    this.emit("order:placed", order);
  }
}

const orders = new OrderService();

// Observers subscribe without the subject knowing what they do.
orders.on("order:placed", (order) => {
  console.log(`Email: confirming order ${order.id}`);
});

orders.on("order:placed", (order) => {
  console.log(`Analytics: tracking $${order.total}`);
});

orders.placeOrder({ id: "A-1001", total: 49.99 });

Output:

Email: confirming order A-1001
Analytics: tracking $49.99

Listeners run synchronously in registration order. Use once() to auto-unsubscribe after the first event, and off() (alias removeListener()) to detach. In CommonJS the import is const { EventEmitter } = require("node:events").

Tip: Always register a listener for the special error event. If an EventEmitter emits error with no listener attached, Node throws and the process crashes.

A custom subject implementation

Building the pattern yourself is worthwhile when you want a typed update contract, observer objects rather than loose callbacks, or behavior that does not map onto named events. The skeleton is small.

class Subject {
  #observers = new Set();

  subscribe(observer) {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer); // unsubscribe handle
  }

  notify(data) {
    for (const observer of this.#observers) {
      observer.update(data);
    }
  }
}

class TemperatureSensor extends Subject {
  setReading(celsius) {
    this.notify({ celsius, at: new Date().toISOString() });
  }
}

const sensor = new TemperatureSensor();

const logger = { update: ({ celsius }) => console.log(`Logged ${celsius}°C`) };
const alarm = {
  update: ({ celsius }) => {
    if (celsius > 30) console.log(`ALARM: ${celsius}°C too hot`);
  },
};

const unsubscribe = sensor.subscribe(logger);
sensor.subscribe(alarm);

sensor.setReading(22);
sensor.setReading(34);

unsubscribe(); // logger stops receiving updates
sensor.setReading(18);

Output:

Logged 22°C
Logged 34°C
ALARM: 34°C too hot

Returning an unsubscribe function from subscribe is the modern convention — it spares observers from holding a reference back to the subject just to detach later.

Observer vs. pub/sub messaging

People use “observer” and “pub/sub” interchangeably, but they differ in coupling and topology. In the observer pattern the subject holds direct references to its observers and calls them in-process. In publish/subscribe messaging, publishers and subscribers communicate through an intermediary — a message broker or event bus — and never know about each other at all. That broker can buffer, route by topic, fan out across processes, and survive consumer restarts.

AspectObserverPub/Sub messaging
CouplingSubject references observersDecoupled via a broker
DeliverySynchronous, in-processOften async, cross-process
TopologyOne subject, many observersMany publishers, many subscribers
PersistenceNone (in-memory)Broker can persist/replay
Typical toolsEventEmitter, custom subjectsRedis, RabbitMQ, Kafka, NATS

A rough heuristic: reach for EventEmitter when the producer and consumer live in the same process and you want low overhead; reach for a broker when events must cross service or process boundaries reliably.

Best practices

  • Prefer the built-in EventEmitter over a custom subject unless you genuinely need an update-shaped contract or non-event semantics.
  • Always attach an error listener so an emitted error cannot crash the process.
  • Return an unsubscribe function (or use AbortSignal with events.on) so observers can detach without leaking references.
  • Watch for the default 10-listener warning; if a high count is intentional, raise it with emitter.setMaxListeners(n) rather than ignoring the warning.
  • Keep listener bodies fast and non-blocking, since synchronous listeners run on the emitter’s call stack and stall the event loop.
  • Reach for a message broker once events must survive restarts or cross process boundaries — the in-memory observer pattern offers no durability.
Last updated June 14, 2026
Was this helpful?