Appearance
9.4.13 — Observer
What the original Gang of Four book says: Set up a one-to-many relationship between objects, so that when one object changes state, everything that depends on it is told automatically.
What that means when you are actually writing code: When "X just happened" needs to trigger five completely unrelated reactions, do not make X call all five of them. Let X announce that it happened, and let the five sign up to hear about it.
Observer is the pattern that made event-driven programming possible, and it is also the pattern whose costs are taught least honestly. Almost every tutorial shows you the happy version where a subject notifies two listeners and everything is beautifully decoupled. Almost none of them show you the memory leak, the ordering bug, or the confirmation email that gets sent for an order which was never actually saved.
You are already surrounded by this pattern. Every addEventListener, every emitter.on, every React subscription to an external store, every Kafka consumer group is Observer. Learning it properly means learning both halves at once. There is the decoupling you gain, which is real and valuable, and there are the four failure modes you get handed along with it, which are equally real and which show up in production rather than in tutorials.
1. The story: the placeOrder method that grew a tail
The first version of order placement did exactly one thing, and it did it clearly:
typescript
async function placeOrder(cart: Cart, user: User): Promise<Order> {
const order = await orders.insert({ items: cart.items, userId: user.id });
return order;
}Then the requirements started arriving, one per sprint. Each of them was perfectly reasonable when considered on its own, and each was added by a different team who had no reason to think about the shape of the function as a whole:
typescript
async function placeOrder(cart: Cart, user: User): Promise<Order> {
const order = await orders.insert({ items: cart.items, userId: user.id });
await email.sendConfirmation(user.email, order); // sprint 2
await inventory.decrement(order.items); // sprint 3
await analytics.track("order_placed", { order, user }); // sprint 4
await loyalty.awardPoints(user.id, order.total); // sprint 5
await warehouse.enqueuePickList(order); // sprint 6
await crm.updateLastPurchase(user.id, order.total); // sprint 7
if (user.isFirstOrder) await email.sendWelcomeSeries(user); // sprint 8
if (order.total.gt(Money.of(500_00))) await fraud.review(order); // sprint 9
await slack.post("#sales", `New order ${order.id}`); // sprint 10
return order;
}Every single one of those lines is defensible. Nobody made a bad decision. The aggregate is nevertheless a serious problem, and it is worth being precise about exactly why, because the reasons are what justify the pattern.
The order module now depends on eight other systems. Email, inventory, analytics, loyalty, warehouse, CRM, fraud detection and Slack. To compile this module you need all eight of them available. To write a test for it you have to build eight fakes. To understand it during an incident you have to know what all eight do. And look at the ratio: the code that expresses the actual purpose of this function, which is recording that an order exists, is one line out of eleven. The other ten lines are things that happen to be interested in that fact.
Every new reaction means editing this function. This is the Open/Closed problem again, and this time it lands on the single most business-critical function in the entire system. When the marketing team decides they want a Slack notification, that decision becomes a code change inside the checkout path. The risk profile of that change is completely out of proportion to its value.
A failure in a trivial concern kills a valuable operation. Suppose Slack is having an outage. The call to slack.post throws. The exception propagates. A customer who has already paid now sees their order fail. Notice what has gone wrong conceptually: in the code, line three is exactly as fatal as line one, because they are both await calls in the same try-less block. In the business, recording the order is essential and posting to Slack is decorative. The code has no way to express that difference, and so it treats them identically.
Latency accumulates in the request path. Nine sequential network calls, each waiting for the previous one to finish, all happening while the customer stares at a spinner. The customer is now waiting for a CRM update that they will never see and do not care about.
Different teams keep editing the same function. Loyalty, growth and fulfilment all have commits in placeOrder. It is a permanent merge-conflict magnet, and because everybody edits it, nobody owns it. When something goes wrong here at two in the morning, there is no obvious person to page.
Now look at what the code is really trying to say. There is one fact — an order was placed — followed by nine separate parties who care about that fact for their own reasons. The code has expressed this by making the order module responsible for knowing all nine parties, which is backwards. Observer flips the direction. The fact gets announced once, and the interested parties are responsible for listening:
typescript
const order = await orders.insert(...);
await events.emit(new OrderPlaced(order, user)); // ← one line; nine listeners live in other modules
return order;The checkout path is now two lines long and depends on nothing but the order repository and the event bus.
2. How you arrive at the pattern
Step 1 — Start naive, and stay there longer than you think. Call each reaction directly, right where it happens.
This is genuinely the correct design in three situations, and it is worth knowing them because they are the situations where Observer will make your code worse. It is correct when there is only one reaction. It is correct when the reaction is part of what the operation actually means, because an order that has not been recorded is not an order at all. And it is correct when you need a result back from the reaction, since you cannot get a return value from an announcement.
Step 2 — Wait for the force. The force here has several parts, and all of them need to be present. One event has acquired many independent reactions. The list of those reactions keeps changing as the business grows. The reactions are not the subject's business, meaning the order module has no natural reason to know about loyalty points. And critically, a broken reaction must not be allowed to break the subject.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | who cares about this event, how many of them there are, and what each of them does about it |
| What stays fixed | the event itself — the fact that an order was placed, together with the data describing it |
The thing that stays fixed becomes a message type that you define once. The things that vary become listeners, registered from outside the module that emits.
Step 4 — Decide when the choice gets made. For Observer the answer is at runtime, and unusually, it keeps changing throughout the life of the program. Listeners come and go while the application is running. A React component subscribes when it appears on screen and unsubscribes when the user navigates away.
That constant coming and going is Observer's best feature, because it is what lets a user interface stay in sync with data it does not own. It is also the direct source of Observer's worst bug, which is covered in detail in section 6.
Step 5 — Name the pattern, and be very clear about the costs. The name is Observer. You will also see it called Publish/Subscribe, Listener, or Signal, depending on which ecosystem you are in.
The costs of this particular pattern are unusually heavy, and you should be able to recite them from memory, because "let's just use events" is the single most common over-application in this entire catalogue. Reciting the costs is what stops a team from turning a working system into an untraceable one.
You lose the ability to see the control flow. Reading the line emit("order.placed") tells you absolutely nothing about what happens next. The single most useful question in software maintenance is "find all the callers of this", and after you adopt an event bus, your IDE can no longer answer it. That is a genuine, permanent loss, and you are trading it for decoupling.
Memory leaks become the default behaviour. A listener that is no longer useful is still held alive by the subject that it registered with. This is not an edge case. It is the number one Observer bug in real systems, and section 6 covers it in full.
Listener order is undefined. Listeners run in whatever order they happened to register, which is decided by module import order, which changes when somebody reorders their imports or when a bundler splits a chunk differently. Any dependency between two listeners is therefore a bug that will appear on a random deploy with no related code change.
Error handling becomes genuinely hard. One listener throwing must not break the others, because they are supposed to be independent. But it also must not be silently swallowed, because then a broken listener can fail quietly for weeks. Satisfying both of those constraints at once takes deliberate design, which section 5 walks through.
Debugging gets harder. A stack trace originating inside an asynchronous listener does not contain the frames of whatever emitted the event, unless you have done extra work to make that happen.
Payloads drift silently. If you change the shape of an event's data and your emitter is untyped, nothing fails to compile. Listeners break at runtime, in production, inside a module owned by a different team who did not know the change was happening.
The trade is worth making when reactions are genuinely independent and the list is genuinely open-ended. The trade is a bad one when you have two known reactions and simply wanted a bit of decoupling, because then you pay all six costs and gain very little.
3. The mental model
In one sentence: an observer relationship works like a magazine subscription, where the publisher prints one issue without knowing or caring who reads it, and readers subscribe and cancel without the publisher having to change anything.
The analogy that makes it stick — a newspaper. A newspaper does not maintain a list of "people who might be interested in a fire downtown" and telephone each of them individually. It publishes. Whoever subscribed receives it.
Think about what that arrangement buys the publisher. Their workload stays exactly the same whether there are ten subscribers or ten million. New subscribers can be added without the publisher ever knowing it happened. And any subscriber can cancel at any moment without asking permission.
Now think about what it costs. The publisher can never find out whether anybody actually acted on the news. And if a subscriber moves house without cancelling, papers pile up on an empty porch forever, delivered faithfully to somebody who is not there. That second one is not a loose analogy. That is precisely, mechanically, the memory leak, and it is worth holding on to that image because it makes the bug memorable.
When to reach for it. The trigger phrases show up in planning conversations before they show up in code:
- "when X happens, we also need to do Y and Z", where Y and Z have nothing to do with each other
- "the UI should update whenever the data changes"
- "several different parts of the app need to react to this"
- "I don't want the core order module to know anything about analytics"
- "we keep adding another line to this one function every single sprint"
- any requirement phrased using the words "whenever" or "on … event"
When definitely not to reach for it. This list matters just as much, and it is the part people skip.
Do not use Observer when you need a result back. Events are fire-and-forget by nature. If you need a value, call a function, because bending an event bus into a request-response mechanism produces something worse than either.
Do not use it when the reaction is part of what the operation means. Decrementing inventory may genuinely be part of placing an order rather than a reaction to it. If you turn it into a listener, you have created a world where an order can succeed while the inventory update quietly fails, and you have to decide whether that is acceptable. Sometimes it is. Often it is not, and the honest answer is that the step belongs inside the operation.
Do not use it when there is exactly one reaction and it will never change. An event bus serving a single listener is an extra hop with no payoff, and it costs you the stack trace that a direct call would have given you for free.
4. Structure
There are four participants worth naming.
The subject, sometimes called the publisher, owns the state or the operation, and it emits.
The observer, sometimes called the subscriber or listener, registers its interest and then gets notified.
The event is the object describing what happened. Tutorials frequently omit this and pass raw arguments instead. Good production code always has an explicit event type, because that type is what you version, log, serialise and test against.
The bus, sometimes called a dispatcher, is optional. Its purpose is to let the subject and the observer never reference each other at all, not even indirectly.
Push or pull is the classic structural decision. With push, the event carries the data along with it, so you emit OrderPlaced { orderId, total, userId }. With pull, the event carries only a reference to the source, and observers query for whatever they need, so you emit Changed { source } and each observer then calls source.getState().
Push is the sensible default. It makes the event self-describing, which means it can be logged usefully, written to a queue, replayed later and understood by somebody reading a log line six months from now. Its downside is that you have to guess what observers will need, and you will guess wrong at least once.
Pull removes the guessing and stays correct when different observers need genuinely different slices of the data. Its downsides are that observers become coupled to the subject's query API, and that you introduce a race condition, because the state may have changed again between the event being emitted and the observer getting around to reading it.
The practical rule: push the identity and the facts that were true at the moment the event happened, and let observers pull anything that is expensive to compute or rarely needed.
5. The code, walked through line by line
typescript
type Handler<E> = (event: E) => void | Promise<void>;
type Unsubscribe = () => void; // (1) subscribing RETURNS the way out
export class TypedEventBus<Events extends Record<string, unknown>> {
private handlers = new Map<keyof Events, Set<Handler<never>>>();
on<K extends keyof Events>(type: K, handler: Handler<Events[K]>): Unsubscribe {
const set = this.handlers.get(type) ?? new Set(); // (2) a Set: no duplicates, fast removal
set.add(handler as Handler<never>);
this.handlers.set(type, set);
return () => { set.delete(handler as Handler<never>); }; // (3) the closure IS the subscription
}
async emit<K extends keyof Events>(type: K, event: Events[K]): Promise<void> {
const set = this.handlers.get(type);
if (!set) return;
const snapshot = [...set]; // (4) copy, since a handler may unsubscribe
const results = await Promise.allSettled( // (5) one failure must not stop the rest
snapshot.map(async (h) => (h as Handler<Events[K]>)(event)),
);
for (const r of results) {
if (r.status === "rejected") {
this.onHandlerError(type, r.reason); // (6) failures surfaced, never swallowed
}
}
}
private onHandlerError(type: keyof Events, err: unknown) {
logger.error({ event: type, err }, "event handler failed");
metrics.increment("event_handler_failure", { event: String(type) });
}
}
export interface AppEvents { // (7) the contract, written in one place
"order.placed": OrderPlaced;
"order.cancelled": OrderCancelled;
"user.registered": UserRegistered;
}
export const bus = new TypedEventBus<AppEvents>();Each of those numbered lines encodes a decision that is easy to get wrong, so let us go through them properly.
(1) The on method returns an unsubscribe function.
This single design choice prevents the majority of Observer memory leaks in practice, and the reason is worth understanding rather than memorising. It makes cleanup impossible to get wrong, because the returned closure already holds a reference to the exact handler that was registered.
Compare that with the traditional alternative, which is an off(type, handler) method. That approach requires the caller to hold onto the exact same function reference that they passed in originally. The classic failure looks completely reasonable: somebody passes an arrow function to on, then later passes a different but identical-looking arrow function to off. Two arrow functions with the same body are still two different objects, so nothing is removed, and the listener stays registered forever. That bug is invisible in code review because both lines look correct.
(2) A Set rather than an array.
Two benefits come from this. Removal is fast rather than requiring a linear scan, which matters when a busy page adds and removes hundreds of listeners. More importantly, registering the same function reference twice is silently ignored rather than creating a duplicate.
That duplicate case is the second most common Observer bug you will meet. A component subscribes inside an effect that runs again after a re-render, without having cleaned up the previous subscription. The handler now fires twice for every event, then three times, then four, and the symptom that reaches you is "the message appears multiple times" rather than anything that points at subscriptions.
(3) The subscription is nothing more than a closure over the set and the handler. There are no identifiers to track, no bookkeeping table, and no way for a caller to mismatch references.
(4) Loop over a copy of the listener set, not the set itself.
A handler is entirely allowed to unsubscribe itself while it is running, and in fact a "run once then remove me" listener does exactly that as its normal behaviour. Modifying a collection while you are iterating over it is undefined or forbidden in essentially every language. Copying the set first is cheap and it removes a whole family of bugs that are extremely hard to reproduce, because they only appear when a particular listener happens to unsubscribe during a particular event.
(5) Use Promise.allSettled, never Promise.all.
This distinction matters more than it looks. With Promise.all, the first handler that rejects causes the whole thing to short-circuit, so the outcomes of the remaining handlers are never observed at all. Worse than that, all rejects immediately while the other handlers are still running, which means emit returns before the work it started has finished. The caller moves on, the process may exit, and half the reactions vanish.
Promise.allSettled waits for every handler to finish and then reports what happened to each one. That is exactly the semantics Observer requires, because listeners are supposed to be independent, so one failing must neither stop the others nor hide their outcomes.
(6) Failures are logged and counted, and never swallowed.
The worst possible Observer implementation is try { h(e) } catch {}. It looks defensive and responsible, and what it actually does is convert "the loyalty points listener has been throwing an exception for three weeks" into a silent data-loss bug that nobody will discover until a customer complains.
Surface every failure. Log it with the name of the event so it can be searched. Increment a metric, and put an alert on that metric. The whole point of isolating listener failures is that the operation survives; it is not that the failure becomes invisible.
(7) The event map is the contract, and this is the most valuable line on the page.
Because TypedEventBus is generic over AppEvents, the compiler now checks that bus.emit("order.placed", …) is passed a payload of the right shape, and it rejects bus.on("order.plced", …) as a compile error because that event name does not exist.
Stop and consider what that achieves. Observer's worst structural weakness is silent payload drift, where somebody changes an event's shape and listeners in other modules break at runtime. This one generic converts that entire class of production failure into a build failure. It is the single biggest difference between an event bus you can maintain for years and one that everybody is quietly afraid of. The generics techniques involved are covered in 3.7.4.
What this does when you run it:
typescript
const off = bus.on("order.placed", async (e) => { await email.sendConfirmation(e.userEmail, e.orderId); });
bus.on("order.placed", async () => { throw new Error("loyalty service down"); });
await bus.emit("order.placed", { orderId: "o1", userId: "u1", userEmail: "a@b.c", total: 4200 });
// → the confirmation email is sent; the error is logged as "event handler failed";
// emit resolves normally, so the order still succeeds
off(); // ← cleanup, so no leak5.1 The classic object form, and why modern code usually differs
The original Gang of Four version has no bus at all. The subject holds the list of observers itself:
typescript
export class Stock { // the subject
private observers = new Set<PriceObserver>();
private priceCents = 0;
attach(o: PriceObserver): void { this.observers.add(o); }
detach(o: PriceObserver): void { this.observers.delete(o); }
setPrice(cents: number): void {
if (cents === this.priceCents) return; // ← do not notify when nothing changed
this.priceCents = cents; // ← update the state FIRST …
this.notify(); // ← … so observers see a consistent object
}
private notify(): void {
for (const o of [...this.observers]) o.onPriceChanged(this.symbol, this.priceCents);
}
}
export interface PriceObserver { onPriceChanged(symbol: string, cents: number): void; }Two details in there carry real weight and are worth internalising.
Change the state before notifying, never after. An observer is quite likely to call back into the subject while handling the notification, perhaps to read some related value. If you notify first and assign afterwards, that observer sees the old price while being told about the new one. This produces the famous and deeply confusing "the listener read the previous value" bug, where the UI is always exactly one update behind.
Do not notify when nothing actually changed. Emitting on a no-op change causes wasted re-renders at best. At worst, if any observer writes back to the subject as part of its reaction, you have built an infinite loop. Section 6 covers this in more detail.
The bus form dominates modern code for one specific reason: the subject and the observer never need to reference each other, not even through an interface. A listener living in the loyalty module can react to an order event without the loyalty module importing anything from the order module, and without the order module importing anything from loyalty. Neither team needs to know the other exists.
The classic form is still the right choice when the relationship is genuinely about that one object, such as a model and the views rendering it, or a form field and its validators. In those cases you want the connection to be explicit and local rather than routed through a global bus where it becomes invisible.
5.2 Python: the same shape with three different flavours
python
from collections import defaultdict
from typing import Callable, Any
class EventBus:
def __init__(self) -> None:
self._handlers: dict[str, list[Callable[[Any], None]]] = defaultdict(list)
def on(self, event: str, handler):
self._handlers[event].append(handler)
return lambda: self._handlers[event].remove(handler) # the unsubscribe closure
def emit(self, event: str, payload) -> None:
for h in list(self._handlers[event]): # copy before iterating
try:
h(payload)
except Exception:
logger.exception("handler failed for %s", event) # never swallow silentlyThe Python ecosystem gives you three flavours that are worth knowing about, because each represents a different answer to Observer's hazards.
blinker signals, which Flask uses, hold their receivers weakly by default. That means a receiver which gets garbage collected effectively unsubscribes itself, which fixes the memory leak automatically. The trade is that you now have the opposite bug: a receiver that you forgot to keep a strong reference to gets collected while you still wanted it, and silently stops firing. Debugging "my handler randomly stopped working" is considerably harder than debugging a leak, so this trade needs to be made deliberately.
Django signals such as post_save and pre_delete weld the pattern directly into the ORM. They are enormously convenient and they are also the source of Django's most notorious debugging session, which is the one that begins with the question "why on earth did saving a user send an email?" That question is hard to answer precisely because Observer removes the call graph.
asyncio queues are what you reach for when you want decoupling and asynchrony, and they are the in-process ancestor of the durable message queue discussed in section 6.
6. The four hazards, and the fix for each one
Observer's failure modes are consistent enough across every language and framework that knowing them is knowing the pattern. When an interviewer asks "what goes wrong with event-driven code?", this list is the answer they are hoping for.
Hazard 1 — The forgotten listener, which is a memory leak
The subject holds a strong reference to every listener that registered with it. Garbage collection is decided by reachability, not by usefulness. So a listener which is logically dead but was never unsubscribed remains reachable from the subject, and everything that listener's closure captured remains reachable too. That might be an entire React component tree, a database connection, or a thirty-megabyte cached response.
The leak is therefore not one small function. It is an entire object graph, anchored by one forgotten registration.
There is a second effect that is arguably worse than the memory itself. The dead listener keeps running. It reacts to events on behalf of a page the user left ten minutes ago, trying to write into a DOM node that no longer exists.
typescript
// The bug, in exactly the form you will meet it in real code:
useEffect(() => {
socket.on("message", (m) => setMessages((prev) => [...prev, m]));
}, [roomId]); // roomId changes → a new listener is added, the old one stays
// after five room switches: five live listeners, five setState
// calls per incoming message
useEffect(() => {
const off = socket.on("message", (m) => setMessages((prev) => [...prev, m]));
return off; // ← returning the cleanup function IS the fix
}, [roomId]);The fixes, in order of preference.
The first and best fix is to return an unsubscribe closure from on, and then always wire that closure to whatever owns the subscription: component unmount, request completion, or an AbortSignal. This is the design decision from section 5 note 1, and it is preventive rather than corrective.
The second is to tie the subscription's lifetime to an AbortController. Both DOM addEventListener and Node's events module accept { signal } natively. This is the cleanest mechanism available, because one call to abort() tears down many subscriptions at once, and because the lifetime becomes an explicit object you can pass around rather than a discipline you have to remember.
The third is weak listener collections, using WeakRef or an equivalent. These genuinely fix leaks and they genuinely introduce the mirror-image bug described in the blinker discussion above. Reserve them for caches and for listeners whose lifetime you truly cannot control.
The fourth is measurement, which detects rather than prevents. Expose a listenerCount(type) method, assert on it in your tests after teardown, and alert in production when the count only ever grows and never falls. This is exactly why Node prints a MaxListenersExceededWarning when an emitter reaches eleven listeners for one event. That warning is not about a hard limit; it is an early warning that you have probably leaked.
Hazard 2 — Ordering, and the hidden dependencies it creates
Listeners run in the order they registered. That order is determined by the order in which modules were imported, which is determined by your bundler, your entry point, and sometimes by nothing more than alphabetical filenames.
So if listener B quietly assumes that listener A has already run, you have created a bug that will appear on some future Tuesday, on a deploy that contains no change to either listener, because somebody reordered an import block or the bundler split a chunk differently.
The rule is that listeners must not depend on each other's ordering. They must be safe to run in any sequence.
If two reactions genuinely do have an ordering dependency, then they are not two listeners. They are one listener containing two steps, or they are a workflow that deserves to be written out explicitly as described in 9.4.15.
A useful warning sign: if you catch yourself wanting to add priority numbers to listeners so you can control their order, treat that as a design smell before you treat it as a feature request. A priority number is an ordering dependency that you have written down but not actually removed.
Hazard 3 — Errors and transaction boundaries
Two questions need explicit answers, and most codebases have never consciously answered either of them. First: if a listener throws, does the operation that emitted the event fail? Second: do listeners run inside the emitter's database transaction?
| Model | What it means | When to use it |
|---|---|---|
| Synchronous, inside the transaction | a listener failing rolls the whole operation back | reactions that are genuinely part of being correct |
Synchronous, isolated (allSettled plus logging) | the operation succeeds, failures are logged | in-process reactions that are truly optional |
| After commit, in process | emit only once the transaction has committed | reactions that must never see uncommitted data |
| Durable, via an outbox | the event is saved with the transaction and delivered by a worker | anything that must not be lost |
The mistake that causes real incidents is emitting inside a transaction that later rolls back. The listener has already sent a confirmation email saying "your order is confirmed" for an order that, a few milliseconds later, does not exist. The customer has an email and a support ticket, and your database has nothing.
The fix is to collect events during the transaction and emit them after it commits.
For anything that must survive a process crash, you need the transactional outbox, which is a table in your own database where you write the event row in the same transaction as the state change. A separate process then reads that table and publishes the events. Because the event row and the state change commit or fail together, there is no window where one exists without the other. This is covered in depth in 10.8.4.
That outbox is precisely the boundary where in-process Observer grows up into distributed messaging, and being able to name that boundary is worth real credit in an interview.
Hazard 4 — You cannot trace the flow, and cycles become possible
Because emit hides the call graph, and because listeners are perfectly able to emit further events of their own, you can end up with chains of causation that no single person has ever seen in one piece.
Two habits keep this under control.
Name events as past-tense facts. OrderPlaced, never SendEmail. An event named like a command is a design error rather than a naming preference, because it means the emitter is telling a specific listener what to do. At that point the decoupling is fake: you have all the traceability costs of an event bus and none of the independence it was supposed to buy.
Carry a correlation identifier through every event and every log line. Then, even though you cannot see the chain while reading the code, you can reconstruct it afterwards from the logs. Adding a causation identifier as well, naming the event that caused this one, lets you rebuild the tree exactly.
Cycles are the acute version of this problem. Component A's listener updates B. B's change event triggers A's update. That runs forever, usually taking a browser tab or a server process with it.
The guards are straightforward once you know to apply them. Never emit when nothing actually changed, as shown in section 5.1. Make listeners safe to run more than once. Add re-entrancy detection, using a depth counter that throws once it passes a small threshold, because a clear exception is infinitely more useful than a hung process. And in user interface frameworks, prefer computing derived values over having two components subscribe to each other.
7. Where you would actually use this
(a) Domain events inside application services. This is the placeOrder story, and it is the highest-value everyday use of the pattern. The service records the order and emits OrderPlaced. Email, loyalty, warehouse and analytics each subscribe from inside their own modules. What makes it safe rather than reckless is the discipline from section 6: emit after commit, and use an outbox for anything that must not be lost.
(b) User interface state. Every front-end framework's "the screen updates when the data changes" machinery is this pattern with a scheduler bolted on, deciding when to redraw. The framework's subscribe and unsubscribe calls are literally the attach and detach from the classic diagram, and the cleanup function that React demands you return from useEffect exists precisely because of Hazard 1.
(c) The DOM. addEventListener is Observer with two elaborations worth knowing about. Bubbling means the event travels up through ancestor elements, so a single listener on a parent can serve a thousand children, which is the delegation trick that keeps a large table from registering a thousand handlers. And the options object, with { once: true } and { signal }, solves the cleanup problem declaratively rather than leaving it to your discipline.
(d) Node.js EventEmitter. Streams, servers and processes are all emitters, as covered in 3.8.5. Three Node-specific facts matter in practice. An 'error' event with no listener attached crashes the process, which is deliberate, because an unhandled error event is considered a bug rather than a condition. EventEmitter warns you at eleven listeners on one event, because that count usually indicates a leak rather than a design. And emitter callbacks run synchronously, which means a slow listener blocks the entire event loop.
(e) Separating a model from its views. The original Gang of Four example was a spreadsheet model with a table view and a chart view attached. Change the model, and both views update, while the model knows nothing about either of them. It remains the clearest illustration of why the pattern exists at all.
(f) Cache invalidation and read models. A ProductUpdated event clears a cache key, reindexes a search document, and refreshes a materialised view. Three completely independent reactions to one fact, which is the classic shape of a CQRS read-model update (10.8.4).
(g) Metrics, tracing and audit logging. These subscribe to domain events rather than being sprinkled through business logic by hand. That is how you end up with an audit trail that cannot be forgotten when somebody adds a new code path, because the audit listener is attached to the event rather than to the code path.
(h) Plugins and extension points. WordPress hooks, VS Code's onDidChangeTextDocument, and webhooks. A webhook is Observer stretched over HTTP, where unsubscribing becomes a DELETE endpoint and "the listener is currently down" becomes a retry policy with a dead-letter queue.
8. Variants
| Variant | What it looks like | Notes |
|---|---|---|
| Direct (classic) Observer | the subject holds the listener list itself | model and view; explicit and local |
| Event bus | one central dispatcher | subject and listener never reference each other at all |
| Typed event map | Bus<{ "order.placed": OrderPlaced }> | payload safety at compile time; the modern default |
| Push versus pull | the event carries data, or carries a reference | push the facts, let observers pull expensive extras |
| Sync versus async dispatch | an inline call, or a queued one | async needs explicit error and retry rules |
| Once, or take(n) | the listener removes itself after the first delivery | needs the copy-before-iterating trick from section 5 |
| Weak listeners | WeakRef, or Python's blinker | fixes leaks, creates "it silently stopped working" |
| Filtered subscription | on("order.*"), or predicate filters | filter when subscribing, not inside every handler |
| Replay, or late subscribe | a new subscriber receives recent events | RxJS ReplaySubject, Kafka consumer offsets |
| Durable pub/sub | a broker with persistence and retries | crosses the process boundary |
| Reactive streams | Observable plus operators plus backpressure | Observer with composition and flow control added |
Reactive streams deserve their own explanation, because they answer a question that plain Observer simply leaves open: what happens when events arrive faster than the listener can handle them?
Plain Observer has no answer at all. The handler gets called, and if it is slow, either the emitter blocks waiting for it or an internal queue grows without limit until memory runs out.
RxJS and the Reactive Streams specification add backpressure, which is a mechanism letting a slow consumer tell the producer to send less. They also add composition operators such as map, filter, debounce, buffer and switchMap, which turn event handling into a pipeline you can reason about as a whole.
The mental model that keeps them apart: Observer is one event delivered to many handlers. A reactive stream is one sequence of events, transformed by a chain of operators, with an explicit policy for what happens when the consumer falls behind. The same idea applied to bytes rather than events gives you Node streams, covered in 3.8.4.
9. Where you already use it
| What you have used | What announces | Who listens |
|---|---|---|
button.addEventListener("click", fn) | a button on a page | every handler that registered |
Node's EventEmitter and streams | a server, socket or stream | on("data"), on("error") (3.8.5) |
| A webhook you registered with another company | their system | your URL, called when something happens |
| A "someone replied to you" notification | a comment being posted | the app, the email, the phone |
The click handler is worth looking at closely, because it shows the property that matters. A button does not know what happens when it is clicked. It announces the click and stops there. Meanwhile five completely different pieces of code can be listening: one saves the form, one closes a menu, one records that the click happened, one plays a sound. Any of them can be added or removed at any time, and the button's own code never changes and never even finds out.
Now turn it around and imagine the button did know. It would need a line for each of those five things. Adding a sixth would mean editing the button. Removing one would mean editing the button. Every screen using the button would drag along everything any other screen needed. That is precisely the mess this pattern exists to prevent.
10. Ways to get it wrong
Forgetting to unsubscribe. The default failure mode.
The fix:
onreturns a cleanup function, wire it to the owner's lifecycle or to anAbortSignal, assert onlistenerCountin tests, and alert on monotonic growth in production.Swallowing listener errors. A bare
catch {}turns a broken listener into invisible data loss.The fix: log with the event name, increment a metric, and alert on that metric.
Using
Promise.allin an asynchronous emitter. It stops at the first rejection, hides the other outcomes, and resolves before the remaining work has finished.The fix:
Promise.allSettled.Depending on listener order. The fix: make listeners order-independent. If the ordering is real, that is one listener with two steps, or an explicit workflow.
Emitting inside an uncommitted transaction. Listeners react to data that then disappears when the transaction rolls back.
The fix: emit after commit, and use an outbox when durability matters.
Commands disguised as events. Emitting
sendEmailmeans the emitter is dictating the reaction, so the decoupling only looks real while the traceability loss is real.The fix: emit past-tense facts and let listeners decide what to do about them.
Event soup. Everything emits, everything listens, and nobody can trace a single request end to end.
The fix: reserve events for facts that cross module boundaries, use direct calls within a module, propagate correlation identifiers, and maintain a documented catalogue of events.
Mutating a shared event payload. One listener modifies the event object, and every listener after it sees corrupted data.
The fix: freeze event objects so they cannot be modified.
Notifying far too often. Emitting once per keystroke, or once per array element.
The fix: batch, debounce, or emit at a meaningful boundary, and skip notifications when nothing actually changed.
Using events when you need a result. Observer is fire-and-forget. If you need a value back, call a function.
Payload drift. Changing an event's shape breaks listeners silently.
The fix: a typed event map for in-process events, explicit versioning such as OrderPlacedV2 for cross-service events, and consumer-driven contract tests.
11. Observer compared with its neighbours
| Compared with | The difference | Choose Observer when |
|---|---|---|
| Mediator (9.4.1 section 2) | knows every peer by name, talks both ways | reactions are independent, list is open-ended |
| Command | a Command is an instruction aimed at a specific receiver. An event is a past-tense fact with no intended recipient | you are announcing something, not instructing somebody |
| Chain of Responsibility | a Chain passes a request along until one handler takes it. Observer notifies all of them | everybody interested should get to react |
| Strategy | Strategy replaces how one job gets done. Observer adds reactions to a fact | you are extending outward, not swapping an algorithm |
| Message queue | the same shape across process boundaries, with persistence, retries and ordering guarantees | the reaction is in-process; cross the boundary using an outbox |
| Reactive streams | Observer plus operators plus backpressure | you do not need transformation or flow control |
The first row is the comparison people get wrong most often, because both an event bus and a mediator are one object sitting in the middle of a group of components. A mediator, from 9.4.1 section 2, is the object that all the components in a screen talk to instead of talking to each other.
An event bus is one-directional and anonymous. Publishers do not know that subscribers exist, subscribers do not know who published, and the bus itself contains no logic of its own beyond delivery.
A Mediator knows its colleagues by name and contains the coordination rules. A rule like "when the country dropdown changes, reload the state list and then clear the postcode field" is Mediator logic, because it names specific components and specifies an order.
Here is the practical test. If your "event bus" starts accumulating if-statements about which components to notify and in what sequence, it has quietly become a Mediator. That may well be the right design for your situation, but you should call it by its correct name, because the two have very different maintenance properties.
12. Interview calibration
The 45-second answer, in the order you would say it:
Observer separates the fact that something happened from the question of who cares about it. Instead of
placeOrdercalling email, analytics, loyalty and the warehouse directly, which couples the checkout path to eight systems and lets a Slack outage fail a paid order, it emitsOrderPlacedand those reactions subscribe from their own modules.What I gain is that adding a reaction no longer edits checkout, failures stay isolated, and every reaction tests on its own as a plain function. The costs are real and I design for them explicitly. Subscriptions have to return an unsubscribe function wired to the owner's lifecycle, or they leak. Listener errors need
allSettledplus logging and a metric, so that one failure neither stops the others nor hides itself.Listeners must not depend on registration order. And events must be emitted after the transaction commits, with a transactional outbox if the reaction must not be lost. I use a typed event map so that payload changes become compile errors, and I name events as past-tense facts, because an event called
sendEmailis a command in disguise and the decoupling it appears to give you is not real.
Follow-up questions, with the seed of each answer:
- "How do you prevent the leaks?" —
onreturns a cleanup function, wired to unmount, request end, or anAbortSignal. Assert listener counts in tests and alert when the count only ever grows. - "What happens if a listener throws?" —
allSettled, then log with the event name and increment a metric. Nevercatch {}, and never let one listener stop the others. - "Synchronous or asynchronous?" — Synchronous and inside the transaction only when the reaction is part of being correct. Otherwise after commit, and durable through an outbox if losing it would be a business problem.
- "How do you debug event-driven code?" — Correlation and causation identifiers on every event, a documented event catalogue, structured logs at both emit and handle, and tracing spans linking the two.
- "Observer versus pub/sub versus a message queue?" — The same shape at increasing distance: in-process, then cross-module, then cross-process with persistence, retries and replay.
- "When would you not use it?" — When there is one known reaction, when you need a result back, or when the reaction is part of what the operation means.
Recall
- Observer means one fact with many independent reactions. The subject emits a past-tense event and knows nothing about who is listening. The trigger to look for is a function that grows another unrelated call every sprint.
- How you arrive at it: what varies is who cares and what they do about it, and what stays fixed is the event, meaning the fact plus its data. Listeners come and go while the program runs, which is simultaneously the pattern's best feature and the source of its worst bug.
- The four hazards, with a fix for each. This list is the pattern.
- The forgotten-listener leak.
onreturns an unsubscribe closure wired to the owner's cleanup, or bind the subscription to anAbortSignal. AssertlistenerCountin tests and alert on growth in production. - Ordering. Listeners must not depend on each other's order. A genuine dependency means one listener with two steps, not two listeners with priorities.
- Errors and transactions. Use
Promise.allSettledplus logging plus a metric. Nevercatch {}and neverPromise.all. Emit after commit, and use a transactional outbox whenever the reaction must not be lost. - Untraceable flow and cycles. Past-tense event names, correlation identifiers on everything, no notification when nothing changed, and listeners that are safe to run twice.
- The forgotten-listener leak.
- Getting the details right: a typed event map turns silent payload drift into compile errors. Iterate over a copy of the listener set, because a handler may unsubscribe itself. Push the identity and the facts that were true at that moment, and let listeners pull anything expensive. Freeze event payloads so no listener can corrupt them for the next one.
- Do not use it when you need a result back, when the reaction is part of what the operation means, or when there is exactly one reaction that will never change.
- Neighbours: a Mediator knows its colleagues and holds the coordination rules, so a bus that grows if-statements has become one. A Command instructs, while an event announces. A Chain stops at the first handler while Observer notifies all of them. A message queue is the same shape across processes with persistence and replay. Reactive streams add operators and backpressure.
Self-test: Name the four hazards and one concrete fix for each. Why allSettled rather than all? Why must an event be emitted after commit, and what makes delivery durable? What single word in an event's name tells you it is really a command? When is Observer the wrong pattern entirely?
Quiz Bank
FoundationalShow how Observer is derived from a placeOrder method that grew nine reactions, and state precisely what the direct-call version costs.
The naive starting point is to call each reaction inline. That is the right design when there is one reaction, when you need a result from it, or when the reaction is genuinely part of what the operation means.
The force is that one event has acquired many independent reactions, the list keeps changing, the reactions are not the subject's business, and a broken reaction must not be allowed to break the subject.
What the direct-call version costs, item by item. First, the order module now depends on eight separate systems, so compiling it, testing it and understanding it all require knowledge of all eight, while the code expressing the module's actual purpose is one line out of eleven. Second, every new reaction becomes an edit to the most business-critical function in the system, so a marketing team's Slack notification turns into a code change in the checkout path. Third, a failure in a decorative concern kills a valuable operation, because a Slack outage throws an exception and a customer's paid order fails, since the code treats every line as equally fatal while the business does not. Fourth, latency accumulates in the request path, as nine sequential network calls happen while the customer waits for a CRM update they will never see. Fifth, many teams commit to one function, making it a permanent merge-conflict magnet that nobody owns and nobody wants to be paged for.
Drawing the line: who cares and what they do varies, while the event — the fact that an order was placed, plus its data — stays fixed.
When the choice is made: at runtime, and it keeps changing, because subscribers come and go throughout the life of the program.
The resulting pattern is that the service emits OrderPlaced and each reaction subscribes from inside its own module.
What you accept in exchange, and must design for. You lose the ability to see control flow, so "find all callers" stops working. You get memory leaks from listeners nobody removed. You get undefined listener ordering. You get error handling that must isolate failures without hiding them. You get harder debugging. And you get silent payload drift unless you add types.
The trade is worth making when reactions are genuinely independent and open-ended. It is not worth making for two known reactions, where you pay every cost and gain almost nothing.
FoundationalExplain the forgotten-listener leak in full: why it leaks, how it shows up, and every fix along with its trade-off.
Why it leaks. The subject holds a strong reference to every listener that registered with it. Garbage collection works on reachability rather than usefulness, so a listener that is logically dead but never detached stays reachable from the subject. Along with it, everything that the handler's closure captured stays reachable too: a component's props and state, a DOM node, a database connection, a cached buffer. The leak is therefore never one small function. It is an entire object graph anchored by a single forgotten registration.
How it shows up, in the order you will encounter it. The first symptom is duplicated work. After five room switches, five live message handlers fire for every incoming message, so state updates five times and the interface flickers or posts duplicates. The second symptom is errors coming from dead context, where a handler tries to update an unmounted component or write to a closed socket, producing warnings with stack traces that point nowhere useful. The third symptom is memory growth, where resident memory climbs steadily and never falls, eventually ending in an out-of-memory crash in a long-lived process. That third one is the version that pages you at three in the morning.
The fixes, with the trade-off of each.
The first fix is that on returns an unsubscribe closure. This is the primary answer, because it makes cleanup impossible to get wrong: the closure already holds a reference to the exact handler that was registered. The alternative, an off(type, handler) method, requires the caller to hold the identical reference, and the classic failure is passing an arrow function to on and a different but identical-looking arrow to off, which removes nothing at all while looking completely correct in review. The trade-off is that the caller still has to remember to call the closure.
The second fix is to tie the subscription to a lifetime object, using { signal: controller.signal } with DOM addEventListener or Node's events, or a framework's cleanup slot such as the function returned from useEffect. This is the best available mechanism, because one abort tears down many subscriptions simultaneously and the lifetime becomes an explicit thing you can pass around. The trade-off is that a suitable lifetime object has to exist in the first place.
The third fix is weak references, via WeakRef, FinalizationRegistry, or Python's blinker. The subject holds listeners weakly, so a collected listener effectively unsubscribes itself. The trade-off here is severe and often understated: you have converted a leak into the mirror-image bug, where a listener you forgot to keep a strong reference to gets collected while you still wanted it and silently stops firing. "The handler randomly stopped working" is much harder to diagnose than steadily growing memory. Reserve this approach for caches and for listeners whose lifetime you genuinely cannot control.
The fourth fix is instrumentation, which detects rather than prevents. Expose listenerCount(type), assert on it in tests after teardown, and alert in production when the number only ever increases. Node's MaxListenersExceededWarning at eleven listeners exists for exactly this purpose.
The practical policy is to use unsubscribe closures plus lifetime binding as the design, listener-count assertions in tests as the safety net, and weak references only in the narrow cases where lifetime is truly unknowable.
AppliedA listener throws. Separately, an event gets emitted inside a transaction that rolls back. Explain both failures and give the correct rules for error isolation and transaction boundaries.
Failure one: a listener throws. There are three common wrong answers, and each fails differently.
Letting the exception propagate means one broken listener fails the entire operation and prevents every later listener from running. The loyalty service being unavailable now cancels a customer's order, which is absurd but is exactly what a naive for loop does.
Using Promise.all short-circuits at the first rejection, so the outcomes of the remaining handlers are never observed. It is also worse than it looks, because all rejects while the other handlers are still running, so emit returns while work is still in flight and the caller has already moved on.
Using catch {} is the worst of the three, because it looks responsible. What it actually produces is a system where the loyalty listener has been throwing for three weeks and nobody knows, which is silent data loss rather than an error.
The correct rule is that listeners are independent, so a failure must neither stop the others nor hide itself. Use Promise.allSettled. For each rejection, log with the event name and the correlation identifier, and increment a metric that has an alert attached to it. If a specific reaction is genuinely critical, then it should not be a fire-and-forget listener at all: critical reactions belong either inside the operation or in a durable queue with retries and a dead-letter destination.
Failure two: emitting inside a transaction. If placeOrder emits OrderPlaced before the transaction commits and the transaction then rolls back, listeners have already reacted to an order that does not exist. A confirmation email has been sent for a phantom order. Loyalty points have been awarded. A pick list has been queued in the warehouse. The same category of bug appears when in-transaction listeners read from a replica and see pre-commit state.
The correct rules are four models, chosen deliberately for each reaction rather than adopted by default.
The first is synchronous and inside the transaction, where a listener failing rolls the operation back. This is appropriate only when the reaction is genuinely part of the operation being correct, at which point it is arguably not an observer at all.
The second is synchronous but isolated, where the operation succeeds and failures are logged. This suits genuinely optional in-process reactions.
The third is after commit, in process, where you collect events during the transaction and emit them once it has committed. This is the sensible default for domain events, and it fixes the phantom-order bug directly.
The fourth is durable, via a transactional outbox, where the event is written to an outbox table in the same transaction as the state change, and a separate process publishes it afterwards. This is the only model that survives a crash occurring between the commit and the dispatch, and it is the standard answer whenever the reaction must not be lost.
The design rule is to decide per event, write the decision down, and default to after-commit. Reach for the outbox whenever losing the reaction would be a business problem rather than an inconvenience. That decision point is also precisely where in-process Observer becomes distributed messaging.
InterviewA team introduced an event bus and now nobody can trace a request. A single API call fans out into fourteen listeners, three of which emit further events. What went wrong, and how do you fix it without abandoning events?
The diagnosis is that they applied Observer as an architecture rather than as a tool. They lost the property they most needed, which is traceability, and gained coupling they cannot see. The result is a distributed monolith running inside a single process, which is the worst of both arrangements.
Four specific errors are almost always present when this happens.
The first is commands disguised as events. Names such as sendEmail or updateInventory mean the emitter is dictating what the reaction should be, so the decoupling only looks real while the traceability loss is entirely real. That is the worst possible trade.
The second is events used within a single module, where a plain function call would have been clearer, faster and findable with a text search.
The third is chained emission, where listeners emit events that trigger listeners that emit further events, producing a call graph that no human has ever seen in one piece.
The fourth is no catalogue, meaning nothing documents which events exist, what their payloads contain, or who subscribes to them, so the question "what will break if I change this?" simply has no answer.
The fix, keeping events where they genuinely earn their place.
First, restore visibility, because you cannot fix what you cannot see. Attach a correlation identifier and a causation identifier, the latter naming the event that caused this one, to every event. Propagate both through handlers, log structured entries at both emit and handle, and add tracing spans so that the fan-out appears as a tree in your tracer rather than as fourteen unrelated log lines. Then build a live event catalogue, generated from the typed event map, listing every event with its payload type, its publishers and its subscribers.
Second, reclassify everything. Triage each event. Commands in disguise become direct calls or explicit command objects as described in 9.4.15. Events used inside a single module become plain function calls. Only cross-module, past-tense facts remain as events. This step alone typically halves the number of events in the system.
Third, flatten the chains. Cap the emission depth with a counter that throws once it exceeds about three, which converts a mystery into a stack trace. Then rework second-order listeners so they subscribe to the original fact rather than to a derived event, because chains are usually a workflow that deserves to be explicit and owned.
Fourth, set the semantics per event. After commit by default. An outbox wherever loss is unacceptable. allSettled plus alerting metrics everywhere. Listeners that are safe to run twice and independent of ordering, stated as a contract rather than assumed.
Fifth, ratchet the improvement. Require every new event to have a catalogue entry and a named owner, and add a lint rule forbidding emit inside a handler unless it carries an explicit annotation explaining why.
The judgement worth stating out loud is that events are for facts which cross module boundaries and have open-ended reactions, and in most systems that is a genuinely small set. The team's mistake was not using Observer. It was using Observer in the places where a function call was the correct answer, which bought no decoupling at all and sold every bit of traceability.
StaffDesign the event system for a modular monolith moving toward services: domain events across modules, some reactions that must never be lost, some analytics, and one that starts a long workflow. Cover delivery, ordering, schema evolution and the eventual move to a broker.
The organising principle is one way of defining events, three tiers of delivery, and a boundary designed so that moving a subscriber into its own process later is a deployment change rather than a redesign.
Defining events. They are immutable, past-tense objects with a stable identity, shaped as { eventId, type, version, occurredAt, aggregateId, aggregateType, correlationId, causationId, payload }. Use UUIDv7 for the event identifier, because it is time-ordered and therefore doubles as a sort key. Define them in one shared module as a typed map, so that publishers and subscribers share compile-time types today, and so that the same module can later generate JSON Schema for cross-service contracts.
There is one payload rule that matters more than the rest: carry the identity plus the facts that were true at the moment the event occurred, and never carry a mutable reference. A subscriber that re-reads the aggregate later will see a different state and produce subtly wrong results, which is the classic bug where a price-change notification quotes the price the item has now rather than the price it changed from.
Three delivery tiers, chosen per subscriber rather than per event. The first tier is in-process, after commit, best-effort, and it suits analytics, cache warming and Slack notifications. It is cheap, it is isolated with allSettled and metrics, and losing an occasional event is acceptable and monitored. The second tier is durable via a transactional outbox, and it suits emails, loyalty points and invoicing, which is to say anything a customer or an auditor would notice going missing. The event row is written in the same transaction as the state change, and a relay process publishes it afterwards. This is the only design that survives a crash between commit and dispatch, and it is also what makes the eventual move to a broker a no-op for producers. The third tier is a workflow, where a long-running reaction such as fulfilment is not a listener chain at all but an explicit orchestrator started by the event, with persisted state, timeouts and compensating actions (10.8.4).
Modelling a workflow as a chain of listeners is the single most damaging mistake available in this design. A multi-step process with real failure handling needs a state machine that you can query, resume and report on, not an invisible chain that only exists in the runtime.
Delivery guarantees and idempotency. The outbox gives at-least-once delivery, so every durable subscriber must be safe to run twice. Either deduplicate on the event identifier using a processed-events table written in the same transaction as the side effect, or make the effect naturally repeatable by upserting on a key. State this before anyone asks: exactly-once delivery does not exist, and exactly-once effect is achieved by making consumers idempotent (10.4).
Ordering. Global ordering is neither available nor necessary. Per-aggregate ordering usually is. Key the outbox and the broker partition by aggregateId, so that events for one order arrive in order, and design handlers to tolerate out-of-order arrival across different aggregates. Include an aggregate version so a handler can detect and discard a stale event.
Schema evolution, which decides whether this design survives two years. Changes within a version must be additive only, meaning new optional fields. Breaking changes create OrderPlacedV2, published alongside V1 during a migration window, with subscribers migrated one at a time and V1 retired only when its subscriber list is empty, which the catalogue can tell you. Consumer-driven contract tests run in CI so that a publisher's breaking change fails the publisher's build rather than the consumer's production deployment.
Observability. Correlation and causation identifiers on every event. Tracing spans linking emit to handle. A dashboard showing per-subscriber lag and failure rate. Alerts on outbox depth, which is the leading indicator of a stuck relay, and on arrivals in the dead-letter queue.
The move to a broker. Because producers already write to an outbox and subscribers are already idempotent and tolerant of ordering, moving a subscriber out of process becomes a small sequence: point the relay at Kafka or SQS for that event type, run the subscriber as its own deployable service, keep the in-process version running silently for a week while comparing outcomes, and then remove it.
Do not start with the broker. Start with the outbox and the discipline. The discipline is what makes the broker straightforward later, and a broker adopted without the discipline is simply the same mess distributed across more machines, where it is harder to debug.
The summary sentence: define events as immutable past-tense facts in one shared typed catalogue, deliver them in three explicit tiers of best-effort in-process, durable via outbox, and orchestrated workflows for anything long-running, make every durable consumer idempotent and ordered per aggregate, evolve schemas additively with contract tests in CI, and the eventual move to a broker becomes a deployment detail rather than a rewrite.
Flashcards
FlashObserver in one line
One fact, many independent reactions. The subject emits a past-tense event and knows nothing about who listens. Each reaction subscribes from inside its own module.
FlashObserver: the four hazards
Forgotten-listener leaks, undefined listener order, errors and transaction boundaries, and untraceable flow with possible cycles. Knowing these four is knowing the pattern.
FlashObserver: the leak fix
on() returns an unsubscribe closure wired to the owner's cleanup, or bind the subscription to an AbortSignal. Assert listenerCount in tests and alert when the count only ever grows.
FlashObserver: listener errors
Use Promise.allSettled. Never Promise.all, which stops early and hides outcomes, and never catch{}, which is silent data loss. Log with the event name, increment a metric, alert on it.
FlashObserver: transactions
Emit after commit, never inside an uncommitted transaction. If the reaction must not be lost, write the event to an outbox in the same transaction and let a relay deliver it afterwards.
FlashEvent versus command
An event is a past-tense fact with no intended recipient, such as OrderPlaced. A command instructs a specific receiver, such as SendEmail. Emitting a command means the decoupling is fake.
Scenario Drill
DrillBuild the real-time collaboration layer for a document editor: presence, cursors, edits, comments and notifications, with hundreds of clients per document. Show where Observer applies at each layer, and be specific about leaks, ordering, backpressure and fan-out cost.
Observer appears at four separate layers here, and each layer has a different set of constraints. The central mistake teams make is treating all four as one bus.
Layer one is state inside the browser. The document model is the subject, and the editor view, the cursor overlay, the comment sidebar and the outline panel are the observers.
The requirements at this layer are that notification is synchronous, ordered relative to a frame, and batched. A naive "notify on every keystroke" design re-renders four panels for every character typed. So notification happens once per transaction rather than once per character, and observers subscribe to a specific slice of the document so they are only notified when that slice changes. Selector-based subscriptions are exactly why Redux has useSelector and why Vue tracks dependencies automatically.
The dominant risk here is the leak. A comment thread that subscribes to the document and never detaches keeps the entire document graph alive after the thread closes, and in a long editing session that adds up quickly. Assert listener counts in tests.
Layer two is the network transport. The WebSocket is the emitter, and the client subscribes to message types such as edit, presence and comment.
Reconnection is where this layer gets interesting. It is not enough to re-subscribe, because during the disconnection the client missed events. The design therefore needs a sequence number per document and a "give me everything after N" catch-up call, falling back to a full snapshot when N is too old to serve. This is the replay variant of Observer, and it is not optional: without it, a two-second network blip leaves the document permanently diverged from the server's version, and the user will not find out until they reload.
Ordering matters here in a way it did not at layer one. Edits must be applied in server order, or merged using CRDT or operational transformation. So the transport exposes a monotonically increasing sequence number, and the handler buffers out-of-order arrivals rather than applying them as they land.
Layer three is server-side fan-out. The document is the subject and every connected client is an observer.
This is where cost becomes the dominant concern. Five hundred clients multiplied by one edit is five hundred sends, and with per-keystroke edits that becomes tens of thousands of messages per second for a single document.
The mitigations, in order of effectiveness. Batch edits into windows of roughly fifty milliseconds, which is imperceptible to users and gives an order-of-magnitude reduction. Coalesce presence and cursor updates, because only the latest cursor position matters, so dropping intermediate positions costs nothing while dropping edits would be data loss. Differentiate by criticality, so edits are lossless and sequenced while cursors are lossy and coalesced. And shard by document, so that one unusually busy document does not degrade every other one.
Backpressure is mandatory at this layer, and this is where plain Observer runs out of answers. A slow or malicious client must not be allowed to grow an unbounded server-side buffer. Each connection gets a bounded outbound queue with an explicit overflow policy: drop the oldest message for cursors, and for edits, disconnect the client with a "resynchronisation required" close code rather than buffering forever. Buffering forever is how one bad client takes down the server for everybody.
Layer four is cross-service domain events. Events such as CommentAdded, DocumentShared and MentionCreated go to the notification service, the search indexer and the audit log.
This is an entirely different tier of guarantee. A missed mention notification is a user-visible failure, so delivery must be durable: an outbox feeding a broker, consumers that deduplicate on the event identifier, partitioning by document for ordering, and after-commit emission so that a comment which was rolled back never notifies anyone.
The cross-cutting details. Every subscription at every layer is bound to a lifetime, whether that is component unmount, socket close, or request end, and the server tracks subscriber counts per document as a leak alarm. Ordering always comes from per-document sequence numbers and never from registration order. Cycles are prevented by tagging events with their origin, because an incoming remote edit that re-emits as a local edit creates the classic echo loop where two clients bounce the same change back and forth forever. And presence entries carry a time-to-live refreshed by heartbeat, because a client that vanishes without sending a close frame otherwise leaves a ghost cursor on screen indefinitely, which is the distributed equivalent of forgetting to unsubscribe.
The summary sentence: four Observer layers with four different contracts, being batched slice-based subscriptions in the client, a sequenced and resynchronisable transport, a server fan-out that coalesces lossy signals while applying bounded backpressure to lossless ones, and durable outbox-backed domain events for anything a user would notice missing — and the most expensive mistake available is a single undifferentiated bus that treats a cursor position and a document edit as the same kind of thing.