Library
9 min read

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.

Your answer

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.

One event, five reactions
Problems:
orders.markDelivered()returned in 160 ms
  • 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
Who holds the list of subscribers? The subject, in memory: a Set of five callbacks inside the orders app.
markDelivered returned after 160 ms, the sum of all five listeners. Fine while they are all small and in the same app. Now turn on a problem.
Pub/sub here assumes a broker that keeps messages, like Kafka, RabbitMQ with durable queues, or SNS feeding SQS. Redis Pub/Sub does not keep them.

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:

ts
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 A18

Look 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:

ts
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#

ObserverPub/sub
Who holds the subscriber listThe subject, in memoryThe broker
What the sender knowsIts listeners (as callbacks)A topic name
When subscribers runNow, inside notify, one after anotherLater, each at its own pace
The sender's waitThe sum of every listenerHanding the message to the broker
A slow subscriberSlows the senderFalls behind on its own
A failed subscriberThe event is gone once caughtThe message is kept and retried (on most brokers)
Where it livesOne processUsually across services
DebuggingA stack trace through every listenerA message id to search for across logs
What you give upIsolation and durabilitySimplicity, 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.

Not every broker remembers

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.

An in-process event bus

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.

Names you will hear
  • 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
ChoiceWhat you gainWhat you payPick it when
ObserverSimple, 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 brokerThe 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.
TypeScript you just picked up
type Listener<T> = (event: T) => void
A generic function type. T is filled in later, so a Subject of orders gets listeners for orders.
new Subject<{ orderId: string }>()
Fills in T: this subject sends events shaped like { orderId: string }.
(e as Error).message
A type assertion: a caught value is unknown, and this tells the compiler to treat it as an Error.
this.topics.get(topic) ?? []
Nullish coalescing: use the left side unless it is null or undefined, then use [].
this.queues.get(name)?.push(message)
Optional chaining: push only if there is a queue for that name.

In the interview#

Interviewer
Is the observer pattern the same as pub/sub?
You
Same idea, different wiring. In observer the subject keeps its listeners and calls them directly, synchronously, in the same process, so the caller waits for all of them and an event is lost if a listener fails. In pub/sub there is a broker in the middle: publishers and subscribers only know a topic name, delivery is usually asynchronous and across services, and with a durable broker a failed or offline subscriber gets the message later. Node's EventEmitter is observer; Kafka or SNS with SQS is pub/sub.
Leads with the middleman, then the consequences, then real examples.
Interviewer
In your delivery design, which would you use?
You
Pub/sub for the five reactions to order delivered, because they belong to other teams, analytics can be slow, and rider earnings must not be lost. Each consumer has to be idempotent, since a durable broker can deliver the same message twice. Inside one service, for something like clearing a cache when a setting changes, a plain observer is fine and much simpler.
Picks per case, and remembers the duplicate delivery that comes with retries.
Observer or pub/sub?
button.addEventListener("click", save)
The payments service publishes payment.captured, and the ledger, email and fraud services each read it
A React store calling every subscribed component when state changes
A score update sent once and pushed to millions of phones through a messaging service

Checkpoint#

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?

Worth memorising
  • 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.
Say this in 60 seconds

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.