Skip to content
BytePatterns

Observer Pattern Explained: Subscribe, Notify and Unsubscribe

7 min readBytePatterns

The observer pattern explained: a subject that knows only a list of callables, fire-and-forget notify, safe unsubscribe, failing observers and memory leaks.

The observer pattern is the one design pattern almost every programmer has used without naming it: a button's click handlers, a stream's listeners, a webhook, a pub/sub topic. In a low-level design interview it is the standard answer to "and how do the other parts of the system find out?" The core is a few lines. What separates a good answer is the detail: what the subject is allowed to know, what happens when an observer fails or unsubscribes mid-notification, and why forgotten observers leak memory.

The problem it solves

Something happens in one object, and several others need to react. The direct approach is for the source to call each of them: display.halt(); pager.page(); parts.order(). That works until a fourth reaction is needed, and then the source has to be edited, retested and redeployed for a change that has nothing to do with it. The source now depends on every consumer, and it breaks the open/closed principle: adding behaviour means modifying code that was working.

The observer pattern inverts that dependency. The source, called the subject, exposes subscribe. Anything that wants to react, an observer, registers a callable. When the event happens, the subject calls every registered callable in turn. It knows nothing about who they are.

The intuition

The subject's entire knowledge is a list of callables. That list is the design:

  • Subscribe appends to the list. New reactions never touch the subject's code.
  • Notify walks the list in order and calls each entry with the event. It ignores return values: the moment the subject inspects what observers return, it depends on them again. This is fire and forget.
  • Unsubscribe removes an entry. Returning an unsubscribe handle from subscribe is the tidiest way to offer it.

Three details turn the sketch into production code. First, an observer that raises an exception must not stop the others from being notified. Second, an observer may unsubscribe itself, or subscribe another observer, while the subject is looping over the list; iterating over a copy makes that safe. Third, the subject holds a reference to every observer, so an observer that nobody unsubscribes lives as long as the subject does. That is the classic lapsed listener memory leak, and weak references are the usual fix.

Observer is the in-process cousin of publish/subscribe. In pub/sub, a broker or message bus sits between publishers and subscribers, so they do not even share a process; the idea of "one signal, reactions unknown to the sender" is the same.

Watch it run

The animation uses a factory andon cord. It opens with the idea: one event, many reactions, and the source knows none of them. The subject keeps a list of callables, and that list is the design. The line display subscribes: it is appended, not registered by type. Then the supervisor's pager, and the subject learns nothing about either one. Somebody pulls the cord at weld-3: one signal, sent once. The subject walks the list, in order, and calls the first observer, then the second, never reading what they return: fire and forget. A third reaction, the parts window, is one more subscribe, and the source is never reopened. The same pull now reaches three places, and nothing else changed. The last three frames are the warnings: the moment the subject inspects a return value, it depends on who is listening; one watcher that raises takes the rest of the list with it; and the classic bug is a watcher nobody unsubscribed, a leak plus a stale handler firing. The code below handles all three.

Observer Pattern

Step 1 of 12

One event, many reactions — and the source knows none of them.

The same interactive animation as the lesson — step through it with the controls.

The code

A subject with an unsubscribe handle, per-observer error isolation and a copy of the list during notify. The pager unsubscribes and the parts window joins without the Line class changing:

class Line:
    """The subject: it knows only that its observers can be called."""
    def __init__(self):
        self._observers = []

    def subscribe(self, fn):
        self._observers.append(fn)
        return lambda: self._observers.remove(fn)     # the handle that undoes it

    def pull_cord(self, station):
        errors = []
        for fn in list(self._observers):              # a copy: observers may unsubscribe mid-loop
            try:
                fn(station)                           # return value ignored: fire and forget
            except Exception as e:                    # one broken observer must not silence the rest
                errors.append(type(e).__name__)
        return errors

log = []
line = Line()
line.subscribe(lambda s: log.append("board: halt " + s))
stop_pager = line.subscribe(lambda s: log.append("pager: go to " + s))
line.pull_cord("weld-3")
print(log)          # ['board: halt weld-3', 'pager: go to weld-3']

log.clear()
line.subscribe(lambda s: log.append("parts: send kit to " + s))   # the source is never edited
stop_pager()
line.pull_cord("paint-1")
print(log)          # ['board: halt paint-1', 'parts: send kit to paint-1']

A broken observer raises, the subject records it, and the observer after it still runs:

log.clear()
line.subscribe(lambda s: 1 / 0)                       # a broken observer
line.subscribe(lambda s: log.append("audit: " + s))
print(line.pull_cord("press-2"), log)
# ['ZeroDivisionError'] ['board: halt press-2', 'parts: send kit to press-2', 'audit: press-2']

Why the copy matters. Looping over the live list while an observer removes itself shifts the next observer into the slot the loop has already passed, and it is silently skipped:

class NaiveLine:
    def __init__(self):
        self.observers = []

    def pull_cord(self, station):
        for fn in self.observers:                     # iterating the live list
            fn(station)

naive, calls = NaiveLine(), []
def once(s):
    calls.append("once")
    naive.observers.remove(once)                      # unsubscribes itself mid-loop
naive.observers += [once, lambda s: calls.append("second")]
naive.pull_cord("x")
print(calls)        # ['once']

The lapsed listener. A subscribed bound method keeps its object alive after every other reference is gone. Wrapping it in a WeakMethod lets the object be collected:

import gc
import weakref

class Screen:
    def show(self, s):
        pass

strong = Line()
screen = Screen()
strong.subscribe(screen.show)                         # a bound method holds the screen
probe = weakref.ref(screen)
del screen
gc.collect()
print(probe() is not None)                            # True: the subject keeps it alive

class WeakLine(Line):
    def subscribe(self, method):
        ref = weakref.WeakMethod(method)
        def call(s):
            target = ref()
            if target is not None:
                target(s)
        return super().subscribe(call)

weak = WeakLine()
screen = Screen()
weak.subscribe(screen.show)
probe = weakref.ref(screen)
del screen
gc.collect()
print(probe() is None)                                # True: nothing kept it alive

Checked against a brute-force model on 2,000 seeded random sequences of subscribes, unsubscribes and events: every event must reach exactly the observers active at that moment, in subscription order:

import random

random.seed(23)
ok = True
for _ in range(2000):
    subject, got, active, want, handles = Line(), [], [], [], {}
    for step in range(random.randint(1, 30)):
        op = random.random()
        if op < 0.4:
            name = f"o{step}"
            handles[name] = subject.subscribe(lambda s, name=name: got.append((name, s)))
            active.append(name)
        elif op < 0.6 and active:
            name = random.choice(active)
            handles.pop(name)()
            active.remove(name)
        else:
            subject.pull_cord(step)
            want += [(name, step) for name in active]    # brute force: every active name, in order
    ok &= got == want
print(ok)           # True

The complexity

With k observers:

  • Notify: O(k) calls, plus O(k) to copy the list.
  • Subscribe: O(1) appended to a list.
  • Unsubscribe: O(k) to find the entry in a list; a dictionary keyed by a subscription id makes it O(1) and keeps the order.

Where it goes wrong

  • Reading return values. The subject starts to depend on its observers, which is what the pattern was meant to remove.
  • Letting one exception stop the loop. Every observer after the failing one misses the event.
  • Mutating the list while iterating. Observers are skipped or called twice.
  • Never unsubscribing. The subject keeps dead observers alive and fires handlers for screens that no longer exist.
  • Slow observers on the hot path. Notify is synchronous here; heavy work belongs on a queue.
  • Assuming a particular order. Observers should not rely on running before or after each other.

When it shows up in interviews

It comes up in low-level design rounds whenever one change must fan out: a stock price feeding several displays, a parking lot updating its availability board, an order that emails, bills and ships. At system scale the same idea becomes a message topic, covered in SQS vs SNS vs EventBridge and the notification system design.

How to say it in an interview

"The subject keeps a list of callables and knows nothing else about its observers. subscribe appends and returns an unsubscribe handle; notify walks a copy of the list in order and calls each one, ignoring return values, so new reactions never require changing the subject. I isolate failures so one broken observer cannot silence the rest, and I make sure observers are unsubscribed, or held weakly, because otherwise the subject keeps them alive. If an observer does slow work, I move it behind a queue, which is where observer turns into pub/sub."