Skip to main content

Observer Pitfalls: Re-entrancy, Ordering, Memory Leaks

What This Concept Is

The pattern is simple. The failure modes are not. Four that show up in real systems:

  1. Re-entrancy. An observer causes the subject to fire again while the first notification is still running. Stacks get deep; invariants break mid-update.
  2. Subscriber mutation during notify. An observer subscribes or unsubscribes inside update; the subject is iterating its own list and mutates it underneath itself.
  3. Ordering assumptions. Two observers both react to the same event; one assumes the other ran first.
  4. Memory leaks. The subject holds a strong reference to each observer. If an observer is discarded but never unsubscribes, the subject keeps it alive forever (or until the subject dies).

These are the pitfalls that make senior engineers suspicious of naive Observer.

Why It Matters Here

A junior Observer implementation works in the demo and crashes in production during high-frequency events or during component shutdown. Knowing the failure modes is part of knowing the pattern.

You also need this to evaluate library-provided Observer implementations (Node EventEmitter, Java PropertyChangeSupport, Qt signals/slots, RxJS Subject). Each has its own choices on these axes.

Concrete Example

A subject defended against the three most common bugs, in TypeScript:

type Listener<E> = (event: E) => void;

class SafeSubject<E> {
private listeners: Listener<E>[] = [];
private notifying = false;
private pending: Array<() => void> = [];

subscribe(fn: Listener<E>): () => void { // returns an unsubscribe
const mutate = () => { this.listeners.push(fn); };
this.notifying ? this.pending.push(mutate) : mutate();
return () => this.unsubscribe(fn);
}

unsubscribe(fn: Listener<E>): void {
const mutate = () => {
const i = this.listeners.indexOf(fn);
if (i >= 0) this.listeners.splice(i, 1);
};
this.notifying ? this.pending.push(mutate) : mutate();
}

publish(event: E): void {
if (this.notifying) {
throw new Error("re-entrant publish not supported");
}
this.notifying = true;
try {
for (const fn of [...this.listeners]) { // snapshot
try { fn(event); } catch (e) { console.error("observer failed", e); }
}
} finally {
this.notifying = false;
const queued = this.pending; this.pending = [];
for (const op of queued) op();
}
}
}

Key moves: snapshot the list, catch individual observer errors, refuse re-entrant publish, and queue subscription changes made during a notification.

Common Confusion / Misconception

  • "Just use weak references." Helpful in Java (WeakHashMap) and Swift (weak var), awkward in JavaScript (no weak closures until WeakRef, with caveats). It reduces leaks but introduces non-determinism: observers vanish when the GC runs.
  • "Observers are independent; order does not matter." Often true, but if one observer writes to a cache others read, implicit ordering becomes load-bearing. If order matters, document it or make it a queue.
  • Silently swallowing observer exceptions. One observer throwing should not starve the others, but silence also hides bugs. Log at minimum.
  • Async notifications fix re-entrancy. They push the problem elsewhere (now the subject's state may have changed before the observer runs). Async is a trade, not a cure.

How To Use It

Defensive checklist for writing or reviewing an Observer:

  1. Snapshot before iterating. Copy the subscriber list so changes during notify do not corrupt iteration.
  2. Define re-entrancy policy. Disallow, queue, or allow with ordering guarantees. Write it in a comment.
  3. Return an unsubscribe handle. Do not rely on observers remembering to call unsubscribe.
  4. Call unsubscribe on disposal. Tie subscription lifetime to owner lifetime (constructor/destructor, using/try-with-resources).
  5. Isolate observer exceptions. One failing observer should not break the rest.
  6. Document push vs pull. Anyone maintaining it should know what update is given.
  7. Prefer explicit event types. A typed OrderPlaced event is clearer than an untyped tuple.

Check Yourself

  1. What problem does snapshotting the observer list before iteration solve?
  2. Why is an "unsubscribe token" safer than an explicit unsubscribe(observer) call?
  3. What does re-entrancy look like, and what is one policy for handling it?
  4. How does the subject end up leaking memory, and what two techniques mitigate that?

Mini Drill or Application

Write an integration test for a naive Observer that exposes each pitfall:

  1. An observer that subscribes a new observer during update.
  2. An observer that unsubscribes itself during update.
  3. An observer that calls the subject again, triggering re-entrancy.
  4. A short-lived observer that is discarded but never unsubscribed.

Then fix the subject so each test passes without breaking the others.

Read This Only If Stuck