Observer, Events & Callback Lifetimes

The observer pattern's real costs — leaked subscriptions, reentrancy, unbounded fan-out, and the loss of a readable call stack — and what changes when events go from in-process callbacks to a durable log.

Observer inverts a dependency: instead of the producer knowing its consumers, consumers register with the producer and the producer knows only a list of callbacks. That inversion is genuinely useful — it is what lets a UI widget react to a model without the model importing the UI — and it is bought with four costs that are rarely priced in: subscriptions outlive their subscribers, callbacks re-enter the code that is notifying them, fan-out is unbounded, and the call stack stops explaining what happened.

The subscription is a resource #

A registration creates a strong reference from the long-lived publisher to the short-lived subscriber, which is exactly the wrong direction for garbage collection.

java
class Screen {
    Screen(Model model) {
        model.addListener(this::onChange);   // the model now holds this Screen forever
    }
}

Close the screen and it stays reachable through the model's listener list, along with everything it references — often a whole view tree. This is the most common managed-memory leak in event-driven code, and the symptom is a heap that grows with navigation rather than with data.

The fixes, in order of preference:

  • Own the subscription and dispose it. Closeable/IDisposable/Subscription.cancel()/CancellationToken, released in the same scope that created it. Rx's CompositeDisposable, Kotlin's structured CoroutineScope, and Angular's takeUntilDestroyed are all this pattern with framework support — cleanup tied to a lifecycle rather than to a programmer remembering.
  • Weak references, where the framework offers them (WPF's weak event pattern). They stop the leak but introduce nondeterminism: the listener silently disappears when the GC runs, which turns "my handler stopped firing" into a heisenbug.
  • A method reference to a lambda cannot be unregistered. model.addListener(e -> …) creates a new object each call, so removeListener(e -> …) removes nothing. Keep the reference you registered.

Reentrancy and ordering #

Synchronous notification means the publisher's code is on the stack while arbitrary consumer code runs. That consumer can call back into the publisher, mutate the state being announced, subscribe or unsubscribe, or throw.

text
model.set(x)
  └─ notify()
       └─ listenerA.onChange()
            └─ model.set(y)          ← reentrant: a second notify() begins
                 └─ listenerB.onChange()   ← sees y
       └─ listenerB.onChange()            ← then sees x. B observed the states out of order.

Rules that make this manageable:

  • Notify last. Complete the state change and restore the invariant before announcing it, so any reentrant call sees a consistent object.
  • Publish immutable event objects, not the mutable source. A listener that receives the live model can change it mid-broadcast.
  • Decide the ordering contract and document it. Registration order is the usual implicit answer, and any listener that depends on it is coupled to every other listener.
  • Never hold a lock while notifying. Consumer code runs under your lock, calls into its own locks, and the deadlock is between two components that were never designed together (deadlock).

The debuggability cost #

This is the cost that grows with the codebase. A direct call has a stack trace that names every step. An event has none: the publisher's stack ends at notify(), and the question "who is subscribed to this, and why did this handler run?" is answerable only by grep, at run time, across modules.

Chains of events that trigger events are worse — the causal graph is real but exists nowhere in the source. This is the main reason to prefer a direct call when there is a known caller and a known callee. Events pay for themselves when the producer genuinely must not know its consumers; they are expensive when used to decouple two things that were always going to be used together.

In-process events versus an event log #

The vocabulary is shared and the properties are not.

In-process observer Durable event log (Kafka, an outbox)
Delivery synchronous, exactly once, in-process asynchronous, at-least-once, retried
On crash the event is lost the event survives; consumers resume by offset
Consumer failure publisher sees the exception consumer lags; the publisher never knows
Ordering registration order per-partition only
Backpressure none — a slow listener blocks the publisher consumer lag is visible and bounded
Debugging no stack a replayable record, which is better

The at-least-once property is the one that changes application code: every consumer must be idempotent, because a redelivery is normal operation, not an error (idempotency).

Sources

  1. bookDesign Patterns: Elements of Reusable Object-Oriented Software
  2. docsReactive Streams specification (backpressure protocol)
  3. docsJava API — java.lang.ref.WeakReference
  4. docsC# docs — Events and the weak event pattern

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close