skip to content

What is the lapsed-listener problem in the Observer pattern, and how do you prevent it?

level: seniorimportance: must knowfreq 55%

answer

  1. listener list = GC root
  2. short observer, long subject = leak
  3. lambdas can't be unsubscribed by identity
  4. subscribe returns a closeable handle
  5. rising listener count = leak alarm

basics

~20 s

A subject keeps a strong reference to every registered observer. If an observer is discarded without unsubscribing, the subject keeps it alive forever — it leaks memory and keeps receiving events it should no longer handle.

solid answer

~50 s

The subject's listener list is a root that keeps observers reachable, so an observer that goes out of scope without calling detach() is never reclaimed. Worse, it keeps being invoked: a closed screen still repaints, a disposed connection still writes, and the observer often drags a whole object graph (view hierarchy, request context, database session) with it. Symptoms are steady memory growth over a long-lived subject's life, duplicated side effects from re-registering on every reopen, and exceptions from already-disposed observers. The disciplined fix is to make deregistration structural rather than a remembered courtesy: have subscribe() return a subscription handle/token that is closed by whatever owns the observer's lifetime (dispose/close/finally, a composite disposable, a UI effect cleanup, a scope that ends). Weak references are a fallback, not a default: they make delivery nondeterministic and silently drop lambdas or adapters that nothing else holds. Also guard against double-registration and remove-during-notify.

code

pseudocode · 19 lines
pseudocode
// Fragile: nothing to pass to unsubscribe later, and the lambda captures `this`
bus.subscribe(e -> this.refresh(e))

// Robust: subscription handle owned by the same scope that owns the observer
class DetailScreen implements Closeable {
    private subs = CompositeSubscription()

    open()  { subs.add(bus.subscribe(e -> refresh(e)))
              subs.add(model.subscribe(e -> revalidate(e))) }

    close() { subs.closeAll() }        // idempotent; also called from finally
}

// Subject-side hygiene
notify(e):
    for o in observers.snapshot():                  // safe removal during iteration
        try { o.update(e) }
        catch (ObjectDisposed) { observers.remove(o) }   // self-healing
        catch (Exception ex)   { log(ex) }               // one bad observer doesn't stop the rest

go deeper

for a junior

Say that the subject holds a reference to every observer, so forgetting to unsubscribe leaks memory and the dead observer keeps getting called. Name the fix: always unsubscribe when the observer's owner is destroyed.

for a middle

Add why it happens (long-lived subject vs short-lived observer, lambdas you cannot identify at removal, re-registering on reopen) and the mirrored register/unregister-in-finally discipline.

for a senior

Lead with subscription handles and scope/lifecycle binding as the structural fix, discuss weak-reference caveats honestly, and add subject-side hygiene: idempotent registration, snapshot iteration, auto-remove on disposed-observer exceptions, listener-count metrics.

for a principal

Frame subscription as an ownership contract enforced by API shape, not discipline: make subscribe return a Closeable, forbid fire-and-forget registration in review/lint, standardize a composite-subscription idiom per component lifecycle, and instrument listener counts so leaks are detected by monitoring rather than by heap dumps after an incident.

## What the term means "Lapsed listener" describes an observer that is logically dead — the screen closed, the request finished, the component unmounted — but is still registered with a subject that outlives it. It is the single most common production defect caused by the Observer pattern. Two distinct harms, and interviewers want both: 1. **Memory leak.** The subject's collection holds a *strong* reference. Reachability, not intent, determines lifetime in a garbage-collected runtime, so the observer cannot be reclaimed. In manual-memory languages the analogue is a dangling pointer or a refcount cycle that never drops to zero. The observer is rarely small: it usually captures a view tree, a controller, a request/session context, a database connection, or a cache — so one leaked listener can pin megabytes. 2. **Behavioral bug.** The dead observer is still *called*. It repaints a detached widget, writes to a closed socket, mutates a stale model, or double-processes an event because a new instance also registered. These surface as `IllegalStateException`/`use after close`, doubled side effects (two emails, two audit rows), or "ghost" UI updates. ## Why it happens so easily - **Asymmetric lifetimes.** Subjects are typically long-lived (a singleton event bus, an application model, a connection pool); observers are short-lived (a dialog, a request handler, a page). Every asymmetry is a leak waiting for a missing `detach`. - **Lambdas and method references.** `bus.subscribe(e -> this.refresh(e))` creates an object you do not hold, so you cannot pass "the same" observer to `unsubscribe` later. Also, the lambda captures `this`, so it pins the enclosing object. - **Registration on a path with early returns or exceptions.** Register at the top, unregister at the bottom, and any `return`/throw in between skips the cleanup. - **Re-registration on reopen.** Each open adds another listener; nobody removes any; event handling multiplies linearly with how many times the user opened the screen. - **Anonymous adapters.** The observer is a wrapper created inside the subscribe call, so the caller has no handle at all. - **Framework-hidden subscription.** Data binding, dependency injection, or annotation-driven listeners register on your behalf; if you do not know a subscription exists, you cannot know to release it. ## Fixes, roughly in order of preference ### 1. Subscription handles (the default answer) Make `subscribe` return a token whose only job is to undo the subscription: ``` sub = subject.subscribe(observer) // returns Subscription/Disposable/Registration/token ... sub.close() // idempotent ``` This removes the need to identify the observer at removal time (which is what breaks with lambdas), makes the obligation visible in the type system, and composes: collect several handles in a composite and close them all when the owner dies. Variants you will recognize: `Disposable`/`CompositeDisposable`, `Registration.remove()`, `AbortController`/signal, a returned `unsubscribe` closure, an effect's cleanup function. ### 2. Bind the subscription to a scope or lifecycle Let the language or framework end it for you: - `try (var sub = subject.subscribe(o)) { ... }` / RAII destructor / `defer` / `using` — deterministic and exception-safe. - Lifecycle-aware registration: the platform unsubscribes when a component reaches its end state (view destroyed, request completed, effect torn down). - Stream operators that terminate a subscription on a signal (`takeUntil(destroyed)`). The principle: *whoever owns the observer's lifetime owns the unsubscription, in the same construct that ends that lifetime.* ### 3. Symmetric pairing and structural review Register and unregister in mirrored methods — `onAttach/onDetach`, `start/stop`, `init/dispose`, `open/close` — and make it a review rule that `subscribe(` never appears without a corresponding release path. Use `finally` so exceptions cannot skip it. ### 4. Weak references — with explicit caveats The subject stores weak references so an unreferenced observer can be collected. Real, but full of traps: - A lambda/anonymous observer that nothing else holds is collectible **immediately** — the subscription can vanish before the first event, producing flaky, load-dependent behavior. You must keep a strong reference to the listener somewhere, which reintroduces the ownership question. - Delivery becomes nondeterministic (dependent on GC timing), which is a nightmare to test. - The entry itself still occupies a slot until the subject purges cleared references, so you need periodic reaping. - It fixes only the *memory* half; a still-reachable-but-dead observer keeps receiving events. Use weak listeners for genuinely open-ended caches/registries, not as a substitute for lifecycle discipline. ### 5. Defensive subject-side hygiene - **Reject or ignore duplicate registration** (or document that it double-delivers) so re-open bugs surface early. - **Iterate a snapshot / copy-on-write list**, so an observer unsubscribing itself during notification does not corrupt the loop. - **Auto-remove on failure**: if an observer throws `ObjectDisposed`/`Closed`, drop it. - **Expose the listener count** (a metric or a debug endpoint). A monotonically rising listener count is the cheapest possible leak alarm. - **Cap or warn** above a threshold in development builds. ## Diagnosing it in production Take a heap dump and look for the dominator path: the retained objects will be reachable from the subject's listener collection, and the retained-size histogram usually shows one listener class with thousands of instances. In non-GC runtimes, look for the registry holding raw pointers. Symptoms in metrics: old-generation heap growing across otherwise steady load, GC pauses lengthening, and event-handling latency creeping up because the notify loop grows linearly with leaked listeners. ## Summary sentence worth memorizing *The subject's list is a GC root; subscription is an ownership obligation, and every subscribe needs an owner whose end-of-life mechanically performs the unsubscribe.*

  • Why do weak references not fully solve the lapsed-listener problem?
    They address retention, not correctness or determinism. A lambda or adapter that nothing else strongly references becomes collectible immediately, so subscriptions can disappear before the first event, and delivery becomes dependent on GC timing — untestable and load-dependent. An observer that is still strongly held elsewhere but logically dead keeps receiving events. Cleared entries also linger until the subject purges them.
  • You suspect a listener leak in a running service. How do you confirm it?
    Expose or read the subject's listener count over time — monotonic growth under steady load is near-proof. Then take a heap dump and inspect the dominator tree: the leaked observers will be retained by the subject's listener collection, typically thousands of instances of one class, each pinning a view/request/session graph.
  • Registering twice and unregistering once is a common bug. How do you design against it?
    Make registration idempotent (a set keyed by observer identity, or reject duplicates loudly), return a handle whose close() is idempotent and removes exactly that one registration, and pair subscribe/unsubscribe in mirrored lifecycle methods with try/finally so no path can skip the release.

A gym membership on auto-pay. You stopped going months ago, but the gym still has your card on file: it keeps charging (events still delivered, work still done) and keeps you on the roster forever (memory retained). Nobody at the gym is going to notice you left — cancellation has to be initiated by whoever owns the membership.

saying these in an interview costs you the question

  • 'The garbage collector will clean it up' — it cannot, because the subject's list keeps the observer reachable.
  • Treating weak listeners as the default fix without acknowledging that unreferenced lambdas are collected immediately and delivery becomes nondeterministic.
  • Only counting the memory cost and missing that dead observers keep executing side effects.
  • Unsubscribing at the end of a method with no try/finally, so any early return or exception skips it.
  • Assuming unsubscribing a lambda works by passing 'the same' lambda expression again — it is a different object.
  • Removing an observer from the live collection inside the notification loop without iterating a snapshot.

context