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.
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'sCompositeDisposable, Kotlin's structuredCoroutineScope, and Angular'stakeUntilDestroyedare 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, soremoveListener(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.
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).