Observer pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- Observer lets one object (the subject) tell any number of other objects (observers) that something changed, without knowing who they are. Observers subscribe; the subject keeps a list and calls each one on every change.
- It removes the arrow from the core to its side effects: the stock counter no longer imports the search indexer, the alerting code and the cache. Adding a new reaction is a new subscription, not an edit to the core.
- In-process observers run synchronously on the publisher's thread, in its call stack. That makes them simple and ordered, and it means a slow or failing observer slows or breaks the publisher unless you isolate it.
- The classic bugs are about lifecycle and re-entrancy: observers that are never unsubscribed (the lapsed listener leak), observers that unsubscribe or publish while being notified, and notifications that arrive out of order.
- When observers live in other processes, the same idea becomes pub/sub over a broker, with its own delivery guarantees. Keep in-process Observer for cheap, local reactions.
How it works
The problem
An inventory service keeps a stock count per product. When a count changes, four things must happen: the search index updates the "in stock" flag, a low-stock alert fires below 10 units, a product page cache entry is dropped, and the analytics pipeline records the change. The first version does all of that inside set_stock:
def set_stock(self, sku, qty):
old = self.stock[sku]
self.stock[sku] = qty
self.search.update(sku, qty > 0)
if qty < 10 <= old:
self.alerts.low_stock(sku, qty)
self.cache.drop(f"product:{sku}")
self.analytics.record("stock", sku, old, qty)The inventory class now depends on four other teams' code. Each new reaction is a change to the core, and a test of the stock arithmetic needs four fakes. When the analytics client throws, the stock update looks failed even though it succeeded.
Invert the dependency
The subject offers subscribe(callback) and calls every callback when the value changes:
class StockLevel:
def __init__(self):
self.observers = []
def subscribe(self, fn):
self.observers.append(fn)
return lambda: self.observers.remove(fn)
def set(self, sku, old, new):
for fn in list(self.observers):
fn(sku, old, new)Each team registers its own reaction at startup. The inventory code knows only that someone may be listening. This is the dependency inversion principle applied to side effects, and it is how GUI toolkits, DOM events, Node's EventEmitter, reactive stores (MobX, Svelte stores, signals) and the elevator and parking-lot display boards in this course are wired.
Push or pull
A notification can carry the data (push: fn(sku, old, new)) or just say "I changed" and let the observer read what it needs (pull: fn(subject)). Push is simpler and gives each observer a consistent snapshot of that change. Pull lets observers fetch only what they need, but an observer that reads later may see a newer value than the one that triggered it. Most modern APIs push a small event object.
The hard parts are lifecycle and re-entrancy
Three situations break naive implementations.
- Unsubscribe during notification. An observer removes itself (a one-shot listener) or another observer while the subject is iterating the list. Mutating a list while iterating it skips elements or throws. Iterate over a snapshot, and decide whether a listener removed mid-delivery still gets the current event. The DOM says no (removed listeners are flagged and skipped); Node's
EventEmittersays yes (it iterates a copy). - Publishing during notification. An observer reacts to
0 -> 1by setting the value to2. If the subject notifies recursively, observers later in the list see1 -> 2before0 -> 1. Queue nested changes and deliver them after the current round, so every observer sees changes in the same order. - Failing observers. One observer throws. Without isolation the remaining observers are skipped and the exception reaches the code that changed the value. Catch per observer, collect or log the error, and keep going.
The Practice section builds a value cell that handles all three.
Forgetting to unsubscribe
A subject holds a strong reference to every callback, and every callback holds whatever it closes over, often an entire screen or request context. A component that subscribes on creation and never unsubscribes stays in memory forever and keeps reacting to events for a page the user left an hour ago. This is the lapsed listener problem. The fixes are a disposable handle (subscribe returns an unsubscribe function), lifecycle helpers (AbortSignal passed to addEventListener, React's effect cleanup), or weak references (Python's weakref.WeakMethod) when the subject should not keep observers alive.
Reviewing agent-written code
Agents add observers readily and forget teardown, because the subscribe call is in the code they were asked to write and the unsubscribe belongs in a lifecycle method they did not open. In review, look for every subscribe, on or addEventListener and find its matching removal. Also check whether a new observer does slow work (an HTTP call, a database write) synchronously. If it does, it adds that latency to every change, and it belongs behind a queue.
The same shape elsewhere
Observer is the fan-out shape inside one process. The same shape appears in Postgres LISTEN/NOTIFY, Kubernetes informers built on etcd watches, webhook subscriptions, and broker topics. Each one faces the questions this lesson raises at a larger scale: what happens to a slow subscriber, what order events arrive in, and how subscriptions are cleaned up. The event-driven architecture lesson in the system design course covers the cross-process version.
Down the stack
Layer 1The contract
A well-behaved subject promises: every observer subscribed when a change is delivered is called exactly once for it, in subscription order; observers removed before their turn are not called; observers added during a delivery start with the next change; nested changes are delivered after the current one, so all observers see the same order; one observer's failure does not prevent delivery to the others and does not undo the change; and unsubscribing is idempotent. It should also say what it does not promise: in-process observers are not durable, so a crash between the change and the notification loses the notification. If an observer must never miss an event, use an outbox or a log, not a callback list.
Layer 2The mechanism
Node's EventEmitter stores listeners in an _events object keyed by event name. A single listener is stored as the bare function and only becomes an array when a second one is added, because most events have one listener. emit copies the array before calling listeners, which is why removing a listener mid-emit does not affect the current emit. An 'error' event with no listener throws. With more than 10 listeners on one event it prints a MaxListenersExceededWarning, a cheap leak detector.
The DOM's EventTarget keeps an event listener list per target with type, callback, capture, once, passive and signal. Removing a listener sets its removed flag, and dispatch skips flagged entries, so removal takes effect immediately. Dispatch walks capture, target and bubble phases along a path computed before the first listener runs.
Java's CopyOnWriteArrayList, the usual listener list in concurrent code, copies the whole array on every add or remove and lets iterators read a frozen snapshot without locks. That fits listeners exactly: subscriptions are rare and notifications are frequent. Go code often gives each subscriber a buffered channel instead, which turns synchronous observers into asynchronous ones and forces an explicit decision when a subscriber's buffer is full: block, drop, or disconnect.
Layer 3The machine
Notifying N observers costs N indirect calls plus whatever they do. The dispatch itself is cheap: a loop over 20 callbacks with trivial bodies takes well under a microsecond in Go or V8, and a few microseconds in CPython, where each call builds a frame. The cost that bites is that observers run on the publisher's thread. If one of 20 observers makes a 40 ms HTTP call, every set now takes 40 ms, and a request handler that changes three values waits 120 ms.
Allocation per notification adds up at high rates. Copying the listener array, building an event object and boxing arguments are each a heap allocation in JS and Python; at 100,000 changes per second that is several hundred thousand short-lived objects per second for the garbage collector. Copy-on-write lists move that cost to subscription time. Each observer closure also pins memory: the closure, its captured environment, and everything reachable from it. A leaked listener on a long-lived subject keeps megabytes alive, and heap snapshots show it as a retaining path from the subject's listener array.
Concurrency adds two rules. Never call observers while holding the subject's lock, because an observer that calls back into the subject deadlocks, and a slow observer blocks every other thread. Copy the list under the lock, release it, then notify. And with Go channels, an unbuffered send to a subscriber that has stopped reading blocks the publisher forever; buffer, use select with a default branch to drop, or cut the subscriber off.
Why it's built this way
- The core changes rarely, and the set of reactions to its changes grows with every team, so the core must not depend on the reactions.
- A list of callbacks inverts that dependency at the cost of one loop and N indirect calls.
- Callbacks run on the publisher's stack, so ordering, re-entrancy and failure isolation must be handled by the subject, not left to luck.
- Callbacks are strong references with captured state, so every subscription needs a teardown path, or memory leaks in proportion to uptime.
- When reactions are slow or must survive crashes, the in-process list is the wrong tool, and the design moves to queues and logs.
Diagram
sequenceDiagram participant C as Caller participant S as Cell participant A as Observer A participant B as Observer B C->>S: set(1) S->>S: value = 1, queue (0, 1) S->>A: notify(0, 1) A->>S: set(2) during delivery S->>S: value = 2, queue (1, 2), return S->>B: notify(0, 1) S->>A: notify(1, 2) S->>B: notify(1, 2) S-->>C: errors from both rounds
Worked example
An online shop changes stock about 800 times per second at peak. The StockLevel subject had five synchronous observers. Four were fast (under 50 us each). The fifth, added for a marketing dashboard, posted every change to an HTTP endpoint with a median of 25 ms and a p99 of 300 ms. Checkout updates stock for every line item, and an average cart has 3 items, so checkout p99 rose by almost a second. During a dashboard outage the HTTP client threw on every call, and because the subject did not isolate failures, the cache-invalidation observer registered after it never ran. Customers saw "in stock" for sold-out items for 20 minutes.
The fix had three parts. The subject now catches each observer's error, logs it with the observer's name, and continues. The dashboard observer only appends to an in-memory buffer (about 1 us), and a background worker ships batches of 500 events every second, dropping the oldest when the buffer passes 50,000 so a dead dashboard cannot exhaust memory. And the cache invalidation moved first in the list with a comment explaining why. Checkout p99 returned to 180 ms, and a later dashboard outage changed nothing customers could see.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Direct calls in the core | Explicit order; easy to trace | Core depends on every reaction | Two or three reactions owned by the same team |
| Synchronous observers | Decoupled; ordered; no infrastructure | Slow or failing observer affects publisher; not durable | Fast, local reactions such as UI updates and cache drops |
| Async observers (channel or queue per subscriber) | Publisher isolated from slow observers | Ordering, buffering and back-pressure decisions | Slow reactions in the same process |
| Event bus or broker topic | Cross-process; durable; replayable | Infrastructure, at-least-once semantics, duplicates | Other services react, or reactions must survive crashes |
| Reactive streams (Rx, signals) | Composition: map, filter, debounce | Learning curve; hidden subscriptions | UIs and dataflows with many derived values |
Pitfalls in production
- Lapsed listeners: components subscribe and never unsubscribe, so memory grows and old screens keep reacting. Fix: return an unsubscribe handle and tie it to the component's lifecycle; alert on listener counts.
- Slow observers adding latency to every change. Fix: keep synchronous observers under a microsecond budget and move anything with I/O behind a queue.
- One exception stopping delivery to the rest. Fix: isolate each observer, record the error with the observer's name, and continue.
- Recursive notification when an observer changes the subject, so observers see changes out of order or the stack overflows. Fix: queue nested changes and drain them after the current round.
- Calling observers while holding the subject's lock, causing deadlocks when an observer calls back. Fix: snapshot the list under the lock and notify outside it.
- Depending on observer order without saying so. Fix: if order matters, make it explicit (priorities or a single composed observer), not an accident of import order.
Leadership lens
- Decide per reaction whether it may fail the change. A ledger entry probably must be in the same transaction; a dashboard update must never block checkout. Observer is for the second kind.
- Ask "what happens if the process crashes between the change and the notification?" If the answer is "we lose money or data", the reaction needs an outbox or log, not a callback.
- Track subscription counts per subject as a metric. A count that only grows is a leak you can catch before the out-of-memory kill.
- Give each observer a name and an owner, and log errors with both. Anonymous lambdas in a listener list are hard to debug at 3 a.m.
- When agents add observers, require the teardown in the same change and a note on whether the observer does I/O.
Check yourself
An observer reacts to a change from 0 to 1 by setting the value to 2. With naive recursive notification, what does an observer later in the list see, and how do you fix it?
Show answer
It sees 1 to 2 before 0 to 1. Queue nested changes and deliver them after the current round so every observer sees the same order.
Why does Node's
EventEmitterstill call a listener that another listener removed during the sameemit, while the DOM does not?Show answer
emititerates a copy of the listener array taken before dispatch. The DOM marks removed listeners with a flag and skips flagged entries during dispatch.Why should a subject never call observers while holding its own lock?
Show answer
An observer that calls back into the subject would deadlock, and a slow observer would block every other thread waiting for the lock.
When should a reaction move from an in-process observer to a message broker?
Show answer
When it lives in another process, is slow, or must not be lost if the process crashes between the change and the notification.
Watch
- Observer Pattern, Design Patterns ep 2 (Christopher Okhravi): push versus pull and the subject and observer interfaces, drawn step by step.
- 10 Design Patterns Explained in 10 Minutes (Fireship): a quick look at observer next to other patterns in JavaScript.
Free resources
- Observer (Game Programming Patterns, Robert Nystrom): an honest discussion of observer costs, including synchronous dispatch and dangling observers.
- Events (Node.js docs):
EventEmittersemantics, including listener copies duringemit, the'error'event and the max listeners warning. - EventTarget.addEventListener (MDN):
once,passiveandsignaloptions for removing listeners with anAbortController. - weakref (Python docs):
WeakMethodand weak collections for subjects that should not keep observers alive. - CopyOnWriteArrayList (Java docs): the snapshot-iteration list that suits listener registries.
- Lapsed listener problem (Wikipedia): the leak that comes from forgetting to unsubscribe.