GUIDE · Behavioral

Observer

Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

lesson2 sectionsruns in browserBehavioral14 asserts116 lines

At a glance

INTENT
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
PROBLEM
When an order ships, email + inventory + analytics must all react — and the order code must not know about (or import) any of them.
SAY
Participants: Subject (attach/detach/notify), ConcreteSubject (holds state, fires on change), Observer (update(subject, event) interface), ConcreteObserver. Pitch: "subscribe to a subject; it pushes changes to everyone listening; unsubscribe to stop."
SEEN IN
Node EventEmitter (streams, http.Server, process); DOM addEventListener & AbortSignal; RxJS Observables; MutationObserver / IntersectionObserver / ResizeObserver; Redux store.subscribe, Zustand, React useSyncExternalStore; Vue watch; WebSocket onmessage.
VS
Pub/Sub adds a broker/channel between publisher and subscribers (they never know each other; often async, cross-process); Observer is direct — the subject holds the observer list. Mediator centralizes LOGIC between peers; Observer just broadcasts "I changed".

The idea#

Picture a package you subscribed to. You did not ask the warehouse to learn your name, your email job, and your inventory job. You put your name on a list for that one box. When the box moves from "paid" to "shipped", everyone still on the list gets the update. Drop off the list and the box keeps moving without you. The box does not contain your job. It only contains the list.

That box is the subject. The people on the list are observers. The subject has attach, detach, and notify. A concrete subject holds the state that changes, here an order status, and calls notify when it changes. A concrete observer implements update and decides what it cares about. Email writes a line only when the next status is shipped. Inventory counts a decrement only when the next status is paid. Audit writes down every transition, because that subscriber asked for all of them.

The order must not import the emailer, the warehouse, or the analytics job. Those subscribers come and go. The order only knows "I have a list." When the status changes, it pushes the event to whoever is attached right now.

Why it matters#

The bug this pattern prevents is a package that grows a new phone number inside itself every time a new person wants news. Ship the order, then call email, then decrement stock, then ping analytics, then the fraud job someone added on a Friday. Now you cannot move a test box without those modules, and removing one subscriber means editing the box. A list keeps the order closed and the reactions open. Attach what you need. Unsubscribe when you do not.

You already do this whenever you subscribe. A store's subscribe runs your listener after each change and hands you a function that takes you off the list. Event emitters, once, and off are that list with extra manners. Streams, servers, and sockets speak this way too: one thing changes, many listeners hear it, and the thing that changed does not grow a method per listener.

Pub/sub is the pattern that sounds identical and is not. A broker sits between the publisher and the subscribers. They never hold each other. The channel might live in another process, and the news might arrive later. Observer is direct. The subject holds the list in memory and calls update itself, the way the package texts the people who wrote their names on its clipboard. Mediator is the other mix-up. A mediator contains the rules between peers: this field clears that field. An observer does not coordinate the list. It says "I changed" and lets each subscriber react on its own.

A small example#

JS
function createOrder(id) {  let status = 'created';  const listeners = new Set();  return {    subscribe(fn) {      listeners.add(fn);      return () => listeners.delete(fn);    },    setStatus(next) {      const prev = status;      status = next;      listeners.forEach((fn) => fn({ id, prev, next }));    },  };} const order = createOrder('o-1');const sent = [];const unsubscribe = order.subscribe((event) => {  if (event.next === 'shipped') sent.push(event.id);});order.setStatus('shipped');unsubscribe();

The order never mentions email. The subscriber decides that only shipped is worth recording. After setStatus('shipped'), sent is ['o-1']. After unsubscribe(), a later status still updates the box and does not call this listener.

What the code below is doing#

The sheet shows the same idea twice.

The first shape uses classes so the names are visible. Subject keeps observers in a Set. attach adds one and returns a function that takes it off the list. notify copies the set before looping, so a subscriber can detach itself in the middle of a delivery without skipping someone or getting called twice. Order is the concrete subject, the package. setStatus remembers the previous status, writes the next one, and notifies with the previous and the next. EmailNotifier, InventoryUpdater, and AuditLog are the concrete observers. Email pushes a line only for shipped. Inventory counts only paid. Audit records every arrow from old status to new.

Three attaches, then paid, then shipped: inventory moved once, email sent one shipped line, and audit has both lines. Unsubscribing audit and detaching inventory means a later "delivered" changes nothing for them, and one subscriber is left on the list. Attaching email a second time does not double the set, so the next shipped event adds one more email, not two. A one-shot subscriber removes itself inside update, hears "returned", and stays silent for "refunded".

The second shape is the version you actually write. An event emitter is a subject keyed by event name. on stays on the list, once drops off after the first emit, off removes a listener, and emit returns false when nobody is listening. The pay event fires, the lasting listener and the one-shot listener both record the first id, and only the lasting listener records the second. Then a small store: getState, setState, subscribe. Two cart updates push 1 and 2 to the subscriber. Unsubscribe. The third update still changes the cart to 3 and does not push another render. That store is the list under the state libraries you already use. Same package. Different names on the clipboard.

When you talk about it, say this: subscribe to a subject. It pushes changes to everyone listening. Unsubscribe to stop.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/observer.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
class Subject {  #observers = new Set();  attach(observer) { this.#observers.add(observer); return () => this.detach(observer); }  detach(observer) { this.#observers.delete(observer); }  notify(event) { for (const o of [...this.#observers]) o.update(this, event); }   // copy: safe if one detaches mid-notify  get observerCount() { return this.#observers.size; }}class Order extends Subject {                             // ConcreteSubject  constructor(id) { super(); this.id = id; this.status = 'created'; }  setStatus(status) {    const prev = this.status;    this.status = status;    this.notify({ type: 'status', prev, next: status });  }}class EmailNotifier {                                     // ConcreteObserver  constructor() { this.sent = []; }  update(order, e) { if (e.next === 'shipped') this.sent.push(`order ${order.id} shipped`); }}class InventoryUpdater {                                  // ConcreteObserver  constructor() { this.decrements = 0; }  update(order, e) { if (e.next === 'paid') this.decrements += 1; }}class AuditLog {                                          // ConcreteObserver  constructor() { this.lines = []; }  update(order, e) { this.lines.push(`${order.id}: ${e.prev} → ${e.next}`); }} const order = new Order('o-1');const email = new EmailNotifier();const inventory = new InventoryUpdater();const audit = new AuditLog();const unsubscribeAudit = order.attach(audit);order.attach(email);order.attach(inventory); order.setStatus('paid');order.setStatus('shipped'); unsubscribeAudit();                                       // detach → no more deliveriesorder.detach(inventory);order.setStatus('delivered');order.attach(email);                                      // Set dedupes: attaching twice doesn't double-notifyorder.setStatus('shipped'); const once = { calls: 0, update(o) { o.detach(once); once.calls += 1; } };   // detaches itself mid-notifyorder.attach(once);order.setStatus('returned');order.setStatus('refunded');
Verified by 9 asserts
JS
assert.equal(order.observerCount, 3);assert.equal(inventory.decrements, 1);assert.deepEqual(email.sent, ['order o-1 shipped']);assert.deepEqual(audit.lines, ['o-1: created → paid', 'o-1: paid → shipped']);assert.equal(audit.lines.length, 2);assert.equal(inventory.decrements, 1);assert.equal(order.observerCount, 1);assert.equal(email.sent.length, 2);assert.equal(once.calls, 1);
02

Idiomatic JS shape: EventEmitter (Node's built-in Observer) + the minimal subscribe() store#

JS
const { EventEmitter } = require('node:events');const bus = new EventEmitter();const seen = [];const onPaid = (o) => seen.push(`paid:${o.id}`);bus.on('order:paid', onPaid);bus.once('order:paid', (o) => seen.push(`first-paid:${o.id}`));bus.emit('order:paid', { id: 1 });bus.emit('order:paid', { id: 2 });bus.off('order:paid', onPaid); // The 10-line store every state library is built on (Redux / Zustand / useSyncExternalStore shape)const createStore = (initial) => {  let state = initial;  const listeners = new Set();  return {    getState: () => state,    setState: (patch) => { state = { ...state, ...patch }; listeners.forEach((l) => l(state)); },    subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); },  };};const store = createStore({ cart: 0 });const renders = [];const unsub = store.subscribe((s) => renders.push(s.cart));store.setState({ cart: 1 });store.setState({ cart: 2 });unsub();store.setState({ cart: 3 });
Verified by 5 asserts
JS
assert.deepEqual(seen, ['paid:1', 'first-paid:1', 'paid:2']);assert.equal(bus.emit('order:paid', { id: 3 }), false);  // emit returns false when nobody is listeningassert.equal(seen.length, 3);assert.deepEqual(renders, [1, 2]);                        // unsubscribed before the third updateassert.equal(store.getState().cart, 3);

See also