skip to content

On which thread do observers run in a classic Observer implementation, and what problems does that cause?

level: middleimportance: nice to knowfreq 34%

answer

  1. notify = plain call on the mutating thread
  2. slow observer = slow writer
  3. wrap each update in try/catch
  4. snapshot under lock, dispatch outside
  5. publish after commit; bounded queues only

basics

~20 s

By default observers run synchronously on the thread that changed the subject's state. So a slow observer blocks the writer, an exception from an observer surfaces in the code that just set a value, and observers may end up on a thread they are not allowed to touch UI or transactions from.

solid answer

~50 s

Classic Observer notification is a plain method call: the subject's notify() runs on whatever thread performed the mutation, inside that thread's stack — and often inside its transaction. Consequences: (1) latency — the writer pays for every observer, so one slow listener degrades an unrelated write path; (2) failure coupling — an unhandled observer exception propagates into the mutator unless each dispatch is wrapped; (3) thread affinity — UI toolkits require updates on the UI thread, so an observer notified from a worker must marshal; (4) deadlock — dispatching while holding the subject's lock calls alien code under that lock; (5) visibility — the listener collection and the state read by observers need proper synchronization or observers may see a torn/stale view. Fixes: snapshot the listener list under the lock and dispatch outside it, wrap each observer call in try/catch with the observer's identity in the log, offer an explicit dispatch strategy (caller thread, UI executor, bounded worker pool), and publish after the state change has committed.

code

pseudocode · 15 lines
pseudocode
// Fragile: alien code under the lock, no isolation, writer pays all costs
synchronized notify(e) { for (o of observers) o.update(e) }

// Safer
notify(e) {
    snapshot = withLock { observers.toArray() }      // short critical section
    for (o of snapshot) {                            // dispatch OUTSIDE the lock
        exec = o.preferredExecutor ?: SAME_THREAD    // observer chooses its thread
        exec.run {
            try { o.update(e) }                      // e is immutable => safe publication
            catch (ex) { log("observer {} failed on {}", o, e, ex) }   // isolate failures
        }
    }
}
// and for persistent state: publish after the transaction commits, not inside it

go deeper

for a junior

Say that observers run synchronously on the same thread that changed the state, so a slow or failing observer directly affects the code that made the change.

for a middle

Add the concrete mitigations: wrap each observer call in try/catch, snapshot the list, marshal to the UI thread when needed, and be aware that the writer pays for all observers.

for a senior

Cover alien-method-under-lock deadlocks, memory visibility and immutable payload publication, transaction fate-sharing and post-commit notification, and the obligations that asynchronous delivery introduces (bounded queues, overflow policy, ordering).

for a principal

Specify the listener contract explicitly — delivery thread, synchrony, error containment, ordering, coalescing, in-flight delivery after unsubscribe — and implement it once in a shared dispatcher; decide organizationally which reactions are allowed to be observers at all versus explicit steps of the operation or outbox-driven asynchronous work.

## The default: caller's thread, caller's stack, caller's transaction ``` account.deposit(100) // thread T -> balance += 100 -> notify(Deposited) // still thread T -> auditObserver.update(...) // still thread T -> emailObserver.update(...) // still thread T, still inside T's transaction ``` Nothing in the pattern introduces a thread, a queue, or a boundary. Everything below is a consequence of that fact. ## Consequence 1 — latency and throughput coupling The mutating call does not return until the last observer returns. A listener that does I/O (sends an email, writes to a remote log, renders a chart) adds its latency to an unrelated write path, and a listener that blocks indefinitely hangs the writer. In a request-handling service this converts "save is 5 ms" into "save is 5 ms plus whatever the newest listener does" — a performance regression that appears in a component nobody edited. *Mitigation*: an explicit dispatch strategy. Give the subject an executor/dispatcher so subscribers can opt into asynchronous delivery with a **bounded** queue and a defined overflow policy (drop oldest, coalesce to latest, or block). Never use an unbounded queue: it converts a latency problem into an out-of-memory crash. ## Consequence 2 — failure coupling A naive `for (o : observers) o.update(e)` lets an exception from observer #2 abort the loop *and* propagate to the caller. So `account.deposit()` fails because the analytics listener had a null pointer. Wrap each dispatch: ``` for (o of snapshot) try { o.update(e) } catch (ex) { log("observer {} failed", o, ex) } ``` Decide deliberately: is an observer failure allowed to invalidate the state change? Usually no — which argues for notifying **after** the change is committed. If some listeners *are* part of the operation's correctness, they should be explicit steps in the operation, not anonymous observers. ## Consequence 3 — thread affinity Many runtimes have thread-confined resources: UI toolkits demand widget updates on the UI/main thread; some transaction, security or request contexts live in thread-local storage. If the state change happened on a background worker, observers inherit that thread and: - UI observers throw "wrong thread" errors or corrupt rendering, - observers that read a request-scoped/transaction-scoped context find it empty, - code that was written assuming single-threaded delivery is suddenly concurrent. *Mitigation*: let each observer declare where it wants to run (subscribe-with-executor), or have UI-side observers marshal explicitly (post to the UI queue). Document the delivery-thread contract; "you will be called on an unspecified thread" is a legitimate contract, "unspecified and undocumented" is not. ## Consequence 4 — locks and alien methods Holding the subject's lock while calling `update()` means running arbitrary third-party code under your lock. The observer may acquire another lock (creating a lock-order inversion → deadlock), call back into the subject (reentrancy on a non-reentrant lock → self-deadlock), or simply be slow (serializing all access to the subject). The standard shape is: ``` snapshot = withLock { observers.toArray() } // short critical section for (o of snapshot) o.update(e) // dispatch outside the lock ``` The accepted trade-off is that the snapshot may be slightly stale — a just-unsubscribed observer can get one last event, which must be documented. ## Consequence 5 — memory visibility and safe publication If observers are notified on one thread and read subject state from another, both the listener collection and the state need proper synchronization (a concurrent/copy-on-write collection, volatile/atomic fields, or immutable pushed payloads). Otherwise an observer may see a half-initialized object or a stale value. Pushing an **immutable** payload sidesteps most of this: the event is safely published once and read-only thereafter. ## Consequence 6 — transactions In a database-backed system, an observer notified inside the writer's transaction sees uncommitted state and shares its fate — if the transaction later rolls back, side effects the observer already performed (email sent, message published) cannot be undone. Two standard remedies: notify **after commit** (a post-commit hook), or record the intent in the same transaction and let a separate process act on it (the transactional-outbox idea). If the observer's work genuinely must be atomic with the change, it is not an observer — make it part of the operation. ## Designing the contract A well-specified listener API states: which thread delivers, whether delivery is synchronous with the state change, whether an observer exception affects the state change or other observers, whether delivery is ordered, whether a just-unsubscribed observer can still receive an in-flight event, and whether events can be coalesced or dropped under load. Every one of those is a question that will otherwise be answered accidentally — and differently — by each implementation.

  • Why should the subject copy its listener list under the lock but call the observers outside it?
    Calling an observer is calling an alien method: it may take another lock in a different order (deadlock), call back into the subject (reentrancy/self-deadlock), or block for a long time (serializing everyone). Copying inside the lock keeps the critical section tiny and race-free; dispatching outside removes those hazards. The documented cost is that a listener removed after the snapshot may still receive the in-flight event.
  • An observer sends an email when an order is saved, and it is notified inside the saving transaction. What can go wrong?
    The transaction may roll back after the email is sent, so the customer is notified about an order that does not exist; the observer may also read uncommitted state, and its own slowness or failure can abort the save. Fix by notifying after commit, or by writing the intent to an outbox in the same transaction and dispatching it separately, with idempotent handling.
  • When you make notification asynchronous, what new obligations appear?
    A bounded queue with an explicit overflow policy (drop, coalesce to latest, or block), a defined delivery-thread contract, ordering guarantees (per-source at best), duplicate/at-least-once handling if retries exist, error reporting that no longer surfaces in the caller's stack, and lifecycle care so a queued event is not delivered to an already-disposed observer.

Announcing news by walking around the office and telling each person face to face. You cannot go back to work until the last conversation ends, one person who wants to argue blocks everyone behind them, and if someone faints mid-sentence the rest of the floor never hears the news. Leaving a note in each inbox (async, bounded, isolated) fixes all three — at the cost of nobody having heard it yet when you sit down.

saying these in an interview costs you the question

  • Assuming Observer notifications are asynchronous or run on a background thread by default.
  • Letting one observer's exception propagate out of the mutating method or abort the loop.
  • Invoking observers while holding the subject's lock.
  • Performing external side effects (email, message publish) from an observer notified inside a not-yet-committed transaction.
  • Making delivery asynchronous with an unbounded queue.
  • Leaving the delivery-thread contract undocumented so observers guess whether they may touch UI or thread-local context.

context