Engineering Atlas › Low-Level Design

Observer pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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.

  1. 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 EventEmitter says yes (it iterates a copy).
  2. Publishing during notification. An observer reacts to 0 -> 1 by setting the value to 2. If the subject notifies recursively, observers later in the list see 1 -> 2 before 0 -> 1. Queue nested changes and deliver them after the current round, so every observer sees changes in the same order.
  3. 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

  1. 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.
  2. A list of callbacks inverts that dependency at the cost of one loop and N indirect calls.
  3. Callbacks run on the publisher's stack, so ordering, re-entrancy and failure isolation must be handled by the subject, not left to luck.
  4. Callbacks are strong references with captured state, so every subscription needs a teardown path, or memory leaks in proportion to uptime.
  5. 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

OptionStrengthsWeaknessesChoose when
Direct calls in the coreExplicit order; easy to traceCore depends on every reactionTwo or three reactions owned by the same team
Synchronous observersDecoupled; ordered; no infrastructureSlow or failing observer affects publisher; not durableFast, local reactions such as UI updates and cache drops
Async observers (channel or queue per subscriber)Publisher isolated from slow observersOrdering, buffering and back-pressure decisionsSlow reactions in the same process
Event bus or broker topicCross-process; durable; replayableInfrastructure, at-least-once semantics, duplicatesOther services react, or reactions must survive crashes
Reactive streams (Rx, signals)Composition: map, filter, debounceLearning curve; hidden subscriptionsUIs and dataflows with many derived values

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. Why does Node's EventEmitter still call a listener that another listener removed during the same emit, while the DOM does not?

    Show answer

    emit iterates a copy of the listener array taken before dispatch. The DOM marks removed listeners with a flag and skips flagged entries during dispatch.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →