Observer vs pub/sub
Published
Both patterns solve the same problem. Something happens (an order is delivered) and several other parts of the system need to react, but the part where it happened should not have to know who they are. The difference is one thing: is there a middleman?
Think of a school. Observer is the class teacher making an announcement in the room. The teacher has the attendance register (the list of listeners), says it once, and everyone in the room hears it straight away. Anyone who is absent today never hears it. Pub/sub is the parents' WhatsApp group. The school posts once to the group and has no idea who is in it. A phone that is switched off gets the message when it comes back on, and the school did not wait for anyone to read it.
When an order is delivered, five things happen: loyalty points, a rating prompt, analytics, rider earnings and an invoice email. Analytics sometimes takes three seconds. Would you wire these as an in-process observer or through a broker? What does the rider's app feel in each case?
One event, two wirings#
These are the same five reactions from the opening of the observer chapter. Switch the wiring, then turn on a problem or two.
- Loyalty pointsran inside markDelivered, 20 ms
- Rating promptran inside markDelivered, 15 ms
- Analyticsran inside markDelivered, 40 ms
- Rider earningsran inside markDelivered, 25 ms
- Invoice emailran inside markDelivered, 60 ms
Observer: the subject calls its listeners#
The subject keeps the listeners in memory and calls each one, in order, inside notify. This
is the version from the LLD course, which catches errors per listener so one bad listener cannot
stop the rest:
type Listener<T> = (event: T) => void
class Subject<T> {
private listeners = new Set<Listener<T>>()
subscribe(fn: Listener<T>) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
notify(event: T) {
for (const fn of this.listeners) {
try {
fn(event)
} catch (e) {
console.log(`listener failed: ${(e as Error).message}`)
}
}
}
}
const delivered = new Subject<{ orderId: string }>()
delivered.subscribe((e) => console.log(`points for ${e.orderId}`))
const stop = delivered.subscribe(() => {
throw new Error('analytics down')
})
delivered.subscribe((e) => console.log(`pay rider for ${e.orderId}`))
delivered.notify({ orderId: 'A17' })
// > points for A17
// > listener failed: analytics down
// > pay rider for A17
console.log('markDelivered done') // markDelivered done
stop()
delivered.notify({ orderId: 'A18' })
// > points for A18
// > pay rider for A18Look at where markDelivered done is printed: after every listener. Everything ran inside
notify, one after another, before the caller got control back. That is what makes observer
simple and fast when the listeners are small. It is also why a slow listener makes the caller
slow, and why the failed analytics call is gone for good: nothing kept the event to try again.
Node's EventEmitter and the browser's addEventListener are observer. Node's documentation
says it plainly: emit calls the listeners synchronously, in the order they were added.
Pub/sub: a broker in the middle#
In pub/sub the publisher hands a message to a broker under a topic name, and returns. Subscribers ask the broker for messages on that topic. The publisher does not hold a list of subscribers, does not call them, and does not wait for them.
Here is a toy broker in a few lines. It keeps one queue per subscriber, which is enough to show the two properties that matter:
class Broker {
private topics = new Map<string, string[]>() // topic to subscribers
private queues = new Map<string, string[]>() // one per subscriber
subscribe(topic: string, name: string) {
this.topics.set(topic, [...(this.topics.get(topic) ?? []), name])
this.queues.set(name, [])
}
publish(topic: string, message: string) {
for (const name of this.topics.get(topic) ?? []) {
this.queues.get(name)?.push(message)
}
}
poll(name: string) {
const waiting = this.queues.get(name) ?? []
this.queues.set(name, [])
return waiting
}
}
const broker = new Broker()
broker.subscribe('order.delivered', 'earnings')
broker.subscribe('order.delivered', 'invoice')
// The order service knows a topic name, not who is listening.
broker.publish('order.delivered', 'A17')
broker.publish('order.delivered', 'A18')
console.log(broker.poll('earnings')) // [ 'A17', 'A18' ]
// The invoice service was down for both. Its messages waited.
console.log(broker.poll('invoice')) // [ 'A17', 'A18' ]publish did not run any subscriber code, so it takes the same time with one subscriber or
fifty. And a subscriber that was away gets what it missed. Real brokers (Kafka, RabbitMQ, SNS
feeding SQS queues) do the same thing across machines, with disks, acknowledgements and retries,
which is the subject of queue vs pub/sub vs event stream.
What the middleman changes#
| Observer | Pub/sub | |
|---|---|---|
| Who holds the subscriber list | The subject, in memory | The broker |
| What the sender knows | Its listeners (as callbacks) | A topic name |
| When subscribers run | Now, inside notify, one after another | Later, each at its own pace |
| The sender's wait | The sum of every listener | Handing the message to the broker |
| A slow subscriber | Slows the sender | Falls behind on its own |
| A failed subscriber | The event is gone once caught | The message is kept and retried (on most brokers) |
| Where it lives | One process | Usually across services |
| Debugging | A stack trace through every listener | A message id to search for across logs |
| What you give up | Isolation and durability | Simplicity, and knowing when everyone has finished |
The last row matters in a design discussion. With pub/sub, publish returning tells you the
broker has the message, not that the invoice was sent. If checkout must not finish until
something has happened (the payment is captured), that call should be a direct call, not an
event.
Redis Pub/Sub is pub/sub with a broker, but it is fire and forget: a subscriber that is disconnected when a message is published never gets it. Redis Streams, Kafka, RabbitMQ with durable queues, and SNS feeding SQS all keep messages until they are processed. "We use pub/sub" does not answer "what happens to the event if the invoice service is mid deploy?". Name the broker and its delivery guarantee.
You can put a broker inside one process, an event bus where code publishes to a topic name. That gets you the decoupling of pub/sub (the publisher knows a name, not the listeners). If it calls handlers straight away it still behaves like observer for speed and failures. Durability and isolation only arrive when the broker is outside the process and writes messages down.
- Node.jsEventEmitter: observer, synchronous, in process
- RedisPub/Sub is fire and forget; Streams keep messages
- RabbitMQBroker with exchanges, topics and durable queues
- Apache KafkaA log that keeps events for replay by many groups
- Amazon SQSAWS queues, often fed by SNS topics for fan out
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Observer | Simple, immediate, no infrastructure. A stack trace shows exactly what ran. | The caller waits for every listener, and an event is lost if a listener fails. | Small, fast reactions inside one app: UI events, cache invalidation, in-module hooks. |
| Pub/sub through a broker | The publisher returns fast, subscribers fail and scale on their own, missed messages wait. | A broker to run, eventual consistency, duplicates to handle, harder tracing. | Reactions in other services, anything slow or flaky, or work that must not be lost. |
type Listener<T> = (event: T) => voidnew Subject<{ orderId: string }>()(e as Error).messagethis.topics.get(topic) ?? []this.queues.get(name)?.push(message)In the interview#
Checkpoint#
1. What is the defining difference between observer and pub/sub?
2. In an in-process observer, analytics takes 3 seconds. What happens to markDelivered()?
3. Your subscriber was offline when an event was published through Redis Pub/Sub. What happens?
- Observer: the subject calls its listeners. Pub/sub: a broker in the middle.
- Observer is synchronous. The caller waits for the sum of every listener.
- With pub/sub the publisher knows a topic, not the subscribers, and returns straight away.
- Not every broker remembers. Redis Pub/Sub drops; Kafka, SQS, durable RabbitMQ keep.
- Retries mean duplicates: pub/sub consumers must be idempotent.
Observer and pub/sub both let something announce an event without knowing who reacts, and the difference is the middleman. In observer the subject keeps its listeners in memory and calls them directly and synchronously in the same process, like Node's EventEmitter or addEventListener. That is simple and immediate, but the caller waits for every listener, a slow one slows the caller, and an event is lost if a listener fails. In pub/sub a broker sits in the middle: the publisher sends to a topic and returns, subscribers read at their own pace, and with a durable broker such as Kafka, RabbitMQ or SNS with SQS a failed or offline subscriber gets the message later. Redis Pub/Sub is the exception, it drops messages for absent subscribers. I use observer for small in-process reactions and pub/sub for reactions in other services or anything slow or important, with idempotent consumers because retries bring duplicates.