← Design patterns
Behavior Subscriptions and lifetimes

Observer

Keep me in the loop.

You add something to a cart. The header badge changes. The open cart panel changes too. One value moved, and several places cared. The interesting part is how those places signed up—and how they leave.

TypeScriptGoRust The same cart in each language.

Definition

The Observer pattern is a one-to-many dependency between a subject and its observers: the subject keeps a list of current registrations and, when its state changes, notifies each one.

After Gamma et al., Design Patterns (1994), Observer

01Start with one direct call

The cart knows its quantity. Does it need to know every view?

With one view, updating the quantity and calling renderBadge() is a clear, useful start. A fragment:

cart.ts
// One view: the cart updates the header badge itself.
class Cart {
  private quantity = 0;

  setQuantity(quantity: number): void {
    if (quantity === this.quantity) return;
    this.quantity = quantity;
    renderBadge(quantity);
  }
}

A header and an independently opening cart panel now care about the same change. If the cart calls each view directly, it must learn which views exist and when each one leaves.

Observer lets interested views register callbacks with the cart. A changed quantity reaches the current subscribers, and whoever opened a view ends its registration when the view goes away.

02Give the cart a subscriber list

The cart notifies; each view decides what to show.

The cart owns the quantity and a list of registrations. subscribe stores a callback and returns an ID; unsubscribe removes the registration with that ID. A changed quantity is stored first, then passed to each registered callback in order. Writing the value the cart already has calls nobody.

Below the cart, two views each read the current quantity, then subscribe a callback that keeps their own copy up to date. This is the whole program the lab runs.

cart.ts
type Listener = (quantity: number) => void;

class Cart {
  private quantity = 0;
  private listeners = new Map<number, Listener>();
  private nextId = 1;

  getQuantity(): number {
    return this.quantity;
  }

  subscribe(listener: Listener): number {
    const id = this.nextId++;
    this.listeners.set(id, listener);
    return id;
  }

  unsubscribe(id: number): void {
    this.listeners.delete(id);
  }

  setQuantity(quantity: number): void {
    if (!Number.isInteger(quantity) || quantity < 0 || quantity > 9) return;
    if (quantity === this.quantity) return;
    this.quantity = quantity;
    // Copy the current registrations, then call each one in order.
    for (const listener of [...this.listeners.values()]) listener(quantity);
  }
}

const cart = new Cart();
let badge = cart.getQuantity();
let panel = cart.getQuantity();

cart.subscribe((quantity) => {
  badge = quantity;
});
const panelId = cart.subscribe((quantity) => {
  panel = quantity;
});

cart.setQuantity(1);
console.log(`badge ${badge}, panel ${panel}`);

cart.unsubscribe(panelId); // the panel closes
cart.setQuantity(2);
console.log(`badge ${badge}, panel ${panel}`);

Each view starts by reading the quantity, because subscribing does not send the current value; a view that subscribes late hears only later changes.

03Follow a close and a change

Close the panel in the middle of a change. Who still hears it?

Experiment

Unsubscribe the panel mid-delivery

The program runs as shown. Now change the badge’s registration like this:

cart.ts
cart.subscribe((quantity) => {
  badge = quantity;
  cart.unsubscribe(panelId);
});
Suppose the badge’s callback unsubscribes the panel while setQuantity(1) is calling it, as in the change above. Does the panel receive 1?
One cart. Independent views.Illustrated from the selected execution step
Cart subscriptions, callback delivery and panel cleanupBoxes identify lesson roles. Values and registrations come from recorded snapshots. Moving values represent synchronous callback calls, not messages or browser paint timing.SUBJECT · OWNS THE QUANTITYno registrationno registrationCart sessionRecorded quantity—REGISTRATIONS○ Header badge○ Cart panelHeader badgeVIEW VALUE—Not initializedCart panelVIEW VALUE—Not initialized

Press Play or Step. Setup, registrations and callbacks follow the recorded program.

Recorded values · synchronous calls · each view owns its registration

What a production cart addsFailure reports, panics and re-entry

The same cart, with two additions. An invalid write is an error instead of being ignored. And one failing view must not stop the others from updating, so setQuantity attempts every callback and returns a report: the ID of each one it called and whether it succeeded. Nothing is rolled back; views that already updated stay updated.

cart.ts
type Listener = (quantity: number) => void;
export type Delivery = { id: number; ok: boolean };

export class Cart {
	private quantity = 0;
	private listeners = new Map<number, Listener>();
	private nextId = 1;
	private notifying = false;

	getQuantity(): number {
		return this.quantity;
	}

	subscribe(listener: Listener): number {
		const id = this.nextId++;
		this.listeners.set(id, listener);
		return id;
	}

	unsubscribe(id: number): void {
		this.listeners.delete(id);
	}

	setQuantity(quantity: number): Delivery[] {
		if (this.notifying) throw new Error('cannot change quantity during notification');
		if (!Number.isInteger(quantity) || quantity < 0 || quantity > 9)
			throw new Error('quantity must be an integer from 0 to 9');
		if (quantity === this.quantity) return [];
		this.quantity = quantity;
		const report: Delivery[] = [];
		this.notifying = true;
		try {
			// One failing view must not stop the others from updating.
			for (const [id, listener] of [...this.listeners]) {
				try {
					listener(quantity);
					report.push({ id, ok: true });
				} catch {
					report.push({ id, ok: false });
				}
			}
		} finally {
			this.notifying = false;
		}
		return report;
	}
}

TypeScript and Go deliver to a copy of the registrations, so a callback that subscribes or unsubscribes mid-delivery changes the next delivery, not the current one. A callback that writes the quantity back while the cart is notifying is refused: the write fails inside that callback (TypeScript throws, Go returns ErrNotifying), so the report marks that callback as failed while the outer write completes. Rust needs no guard: set_quantity borrows the cart mutably, so a callback cannot reach the same cart. If you shared the cart through Rc<RefCell<Cart>> and a callback captured it, the write back would panic with BorrowMutError instead of recursing, and the cart and its callback would keep each other alive unless the callback held a Weak.

The report counts what each language treats as an ordinary failure: any exception in TypeScript, a returned error in Go and Rust. A Go or Rust listener that panics stops the delivery, and every listener after it is skipped. None of the versions waits for async work a callback starts.

Reading the TypeScriptCallbacks, Map, and snapshots

Listener names a function signature. The Map associates a registration ID with a callback and keeps insertion order, which is registration order. Spreading its values into an array copies the list of callbacks for this delivery; the copies are the same functions.

Libraries often return an unsubscribe function from subscribe instead of an ID, so the caller cannot remove someone else’s registration. Node’s EventEmitter, below, takes the other route: you remove a listener by passing the same function back.

Production wiringSession ownership and who ends each subscription

Let the view’s owner hold the registration.

Imagine a cart session shared by a page’s header and a slide-out panel. The session owns the cart state. The header subscribes for as long as it is on the page. Opening the panel creates its subscription; closing it ends that subscription. The quantity-changing code stays the same when a new view arrives.

Who owns what
RoleOwnerWhat it does
State ownerCart sessionValidates writes, stores the value, and notifies current registrations.
Reaction ownerEach viewTurns a quantity into its own visible output. Keeps rendering decisions local.
Lifetime ownerWhoever opens the viewKeeps the registration ID and ends that registration when the view closes.

Keep the cart scoped to the owning session. In server-rendered applications, user-specific state needs the appropriate request or session boundary.

04Meet it in your language

A listener nobody removes keeps listening.

Node’s EventEmitter makes the same registration visible: a caller adds a listener for a named event and later removes it, and Node invokes listeners synchronously in registration order. Named events and special 'error' handling belong to EventEmitter's own contract; our cart instead owns one quantity and a direct list of callbacks.

On a server, the usual bug is a listener added per request and never removed. Below, each open tab streams the cart’s changes; when the tab closes, its listener stays, and so does the response it captured. That is the lapsed listener: a subject keeps a forgotten subscriber, and everything that subscriber captured, alive. After ten of them Node prints MaxListenersExceededWarning: Possible EventEmitter memory leak detected.

stream.ts
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';

// One cart session's changes, shared by every open tab's event stream.
export const cartEvents = new EventEmitter();

// Before: each tab that opens the stream adds a listener; nothing removes it.
export function streamBefore(_request: IncomingMessage, response: ServerResponse) {
  response.writeHead(200, { 'content-type': 'text/event-stream' });
  cartEvents.on('quantity', (quantity: number) => {
    response.write(`data: ${quantity}\n\n`);
  });
}

// After: the stream ends its own registration when the tab goes away.
export function stream(_request: IncomingMessage, response: ServerResponse) {
  response.writeHead(200, { 'content-type': 'text/event-stream' });
  const send = (quantity: number) => response.write(`data: ${quantity}\n\n`);
  cartEvents.on('quantity', send);
  response.on('close', () => cartEvents.off('quantity', send));
}

Remove the same function you added: an arrow written again inside off is a different function and removes nothing. The response’s 'close' event fires when the tab goes away, which is the moment this registration stops being needed. Read Node's listener timing contract ↗

05Compare the simpler choices

Keep the direct call until subscribers need independent lives.

A direct call is clearest for one known dependent. A return value fits when the caller asked for a result, and a derived value fits a calculation over existing state. Observer helps when views attach and leave independently; its callback list and cleanup IDs add a lifetime to own.

What changes when another requirement arrives?
NeedSimpler fitObserver adds
One known view follows one updateA direct call.A list and lifecycle machinery with no second subscriber yet.
Several views open and close independentlyCallers repeat notification and cleanup rules.The subject notifies registrations; each view releases its own ID.
Every participant must complete a required workflowA function with explicit results, or a coordinator.Callbacks run one by one; nothing undoes the earlier ones if a later one fails.

The name “Observer” does not decide notification timing, failure handling, replay, or who keeps whom alive. Those choices belong in the subject’s contract, and they matter even with two listeners.

Decisions the pattern leaves to you
QuestionThis cartA reason to choose differently
Push or pull?Push the new quantity.For a large state, send only “something changed” and let each view read what it needs.
When are listeners called?Inside setQuantity, in registration order.Expensive reactions may need batching or scheduling. Specify how that changes ordering and freshness.
What if a listener fails?Production: continue, and report its ID as failed.A required operation may need an explicit error-returning workflow.
What if subscriptions change during delivery?Each delivery uses the registrations it started with.A richer API must define whether removal affects the current delivery, the next one, or both.
Who keeps whom alive?The cart holds callbacks; the view’s owner unsubscribes.With weak references the subject stops keeping views alive, but a view can then vanish without unsubscribing. Garbage collection never frees a callback the subject still holds.
Slow, async and recursive listenersTiming, cancellation and loops

A slow synchronous listener delays later listeners and the setter’s return. Starting async work in a listener adds a different lifetime: unsubscribe can prevent future calls, but it does not cancel a request already started. Cleanup and cancellation may both be necessary.

A listener that writes back to the same subject can create a loop or make later listeners see confusing order. The TypeScript and Go carts refuse that write, and Rust’s borrow rules prevent it. More involved reactive systems need an explicit policy for scheduling and cycles.

Two neighbours are easy to confuse with Observer. In publish / subscribe, publishers and subscribers never hold each other: a broker routes messages by topic. Here the subject itself holds its subscribers. Mediator gives one coordinator the rules for how participants respond together; a subject’s callback only says that a value changed.

06Decide who owns cleanup

Who ends the registration, and when is Observer wrong?

You close the cart panel, then change the quantity. A log inside its listener still prints. What belongs in the panel’s cleanup?
Placing an order must reserve stock, charge the card and send a confirmation. If the charge fails, the reservation must be released. How do you wire it?

Before you open the answer below, say it in your own words: who owns the value, who owns each reaction, and who ends each registration?

One way to say itThree owners, and the bug when one is missing

The subject owns the value and the list of registrations, and decides when and in what order to notify. Each observer owns its reaction: what it does with the new value. Whoever created a registration owns ending it, at the moment the observer stops needing updates, which for a view is when it closes, not when it is hidden. Forget that last part and you get a lapsed listener: work done for something nobody sees, and memory that is never released. Observer is for optional reactions to a change; steps that must succeed together belong to one coordinator.

Try the same questions with upload progress: which views subscribe, and what event ends each registration?

Practice in the lab

The cart session from the lesson: a header badge that stays on the page, a cart panel the shopper opens and closes, then an “Added to cart” notice, then signing in and out. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • OBSERVER-101 (easy) The closed cart panel keeps rendering
  • OBSERVER-102 (medium) The “Added to cart” notice pops up for every change Pro
  • OBSERVER-103 (hard) Review: after signing in, the cart shows the wrong quantity Pro