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:
// 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.
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.subscribe((quantity) => {
badge = quantity;
cart.unsubscribe(panelId);
}); 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.
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.
| Role | Owner | What it does |
|---|---|---|
| State owner | Cart session | Validates writes, stores the value, and notifies current registrations. |
| Reaction owner | Each view | Turns a quantity into its own visible output. Keeps rendering decisions local. |
| Lifetime owner | Whoever opens the view | Keeps 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.
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.
| Need | Simpler fit | Observer adds |
|---|---|---|
| One known view follows one update | A direct call. | A list and lifecycle machinery with no second subscriber yet. |
| Several views open and close independently | Callers repeat notification and cleanup rules. | The subject notifies registrations; each view releases its own ID. |
| Every participant must complete a required workflow | A 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.
| Question | This cart | A 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?
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