Skip to lesson
‹

Courses / System Design Interview / Chapter 15

Module 2 · Building Blocks · Chapter 15 of 39

Publish-Subscribe

15 sections · ~27 min✓ Fact-checked

Builds on Distributed Caches and Message Queues. Next up: Rate Limiters.

Part 1 · One Event, Many Listeners

The problem with calling everyone

Sunday night, a cricket final, and a food delivery app is taking five times its usual orders. That week the marketing team had launched a small new service: a free dessert on every first order. To hear about orders, it is called by the order service, which already calls billing, the restaurant's tablet, receipts and analytics one after another. Mid-match, the dessert service slows to two seconds a call. Every checkout waits on it, and hungry customers watch a spinner, although billing and the kitchens are fine. The fix is to stop calling everyone: announce each order once and let every interested service listen.

That pattern is publish-subscribe (pub-sub). The order service publishes one event, "order placed", to a topic: a named channel for one kind of event. Every service that cares has a subscription to the topic and receives its own copy. The publisher does not know or care who is listening, so adding the dessert service no longer touches checkout.

Five words carry the rest of this chapter:

TermMeaningIn the example
EventA record of something that happenedOrder 512 was placed at 20:41:03
TopicA named stream of one kind of eventorder-placed
PublisherA service that writes events to a topicThe order service
SubscriptionA named reader of a topic with its own progressBilling, coupons, analytics
SubscriberThe service, or its instances, behind a subscriptionThe coupon service's workers

The key word is subscription: each one keeps its own progress, which is what lets one slow subscriber fall behind without holding up the rest. The next page shows exactly that.

One event, many copies

What actually happens to one event inside the broker? Think of a newspaper: it is printed once, and every subscriber gets a copy at their own door, read whenever they like. A topic works the same way. For each new event, the broker records a pending copy for every subscription, or, in the log-based brokers of the message queue chapter, stores the event once and gives each subscription its own offset into the log. Either way, each subscription has its own backlog.

During the final, the coupon service ships a bad deploy and stops working for a few minutes while orders keep arriving.

Predict before you watch: while the coupon service is down, what happens to billing, and to the coupon offers for those orders?
  • Billing carries on untouched; it reads its own subscription.
  • The coupon events wait in the coupon subscription and are handled when the service recovers. Nothing is lost.
Animated sceneOne order event, two independent subscriptions0:20

Order 512 is placed. The order service publishes one event and moves on

Read it as steps

Watch the coupon subscription's count while the coupon service is down.

The publisher sent each event once and never waited. While coupons were down, billing carried on, and the coupon backlog waited in its own subscription until the service came back. That isolation is the main reason to use pub-sub: one slow or broken subscriber cannot hold up the others.

Queue or topic

A topic and the queues of the message queue chapter look alike: both are brokers holding messages between services. So when do you use which? The whole difference is who gets each message. In a queue, each message goes to one consumer. In a topic, each message goes to every subscription.

Message queuePub-sub topic
Each message reachesOne consumer, whichever takes it firstEvery subscription
The sender knowsWhich queue does the workOnly the topic name
Typical messageA command: "send this receipt", "resize this photo"An event: "order placed", "user signed up"
Adding a new readerIt competes for the same messagesAdd a subscription; nobody else changes
After a message is handledDeletedRemoved from that subscription, or kept for a retention period

The first row decides everything else: one reader per message makes a queue, every reader makes a topic.

The command and event row gives a practical test. A command asks one service to do one thing, and you expect it done once: "charge order 512" goes to a queue. An event states a fact that already happened and lets anyone react: "order 512 placed" goes to a topic. The order service should not need to know that a dessert service exists; it just announces the order.

The two also combine. Billing runs several instances, and they must share billing's copy of each event the way workers share a queue. Pub-sub between services, a queue within each service: the next page shows how that works.

Part 2 · How Delivery Works

Subscriptions and consumer groups

If billing runs 6 instances, they must not each charge the customer. So the instances of one service join a consumer group that shares a single subscription, like a household sharing one newspaper: one copy per house, read by whoever picks it up. Coupons have their own group, analytics another. The rule fits in one line: every group gets every event; within a group, each event goes to one member.

In partitioned brokers, a topic is split into partitions, and each partition is assigned to one member of each group. A group therefore cannot keep more members busy than the topic has partitions; extra members sit idle. Here is a topic with 4 partitions read by three groups:

billing (2 instances)
InstancePartitions
billing-10, 1
billing-22, 3
coupons (4 instances)
InstancePartitions
coupons-10
coupons-21
coupons-32
coupons-43
analytics (1 instance)
InstancePartitions
analytics-10, 1, 2, 3

When an instance joins, leaves or crashes, the broker moves its partitions to the others; this is a rebalance. That group can pause briefly, and an event in progress during the move may be delivered again to the new owner, one more reason subscribers must handle repeats. Each new assignment gets a higher generation number, so a member that was replaced without noticing cannot save progress for partitions it lost, a form of fencing the coordination chapter explains.

A new subscription must also decide where to start. In most classic brokers it sees only events published after it was created. In a log-based broker a new group chooses: begin at the oldest event still kept, or only at new ones.

Size partitions for the busiest group

If coupons need 16 instances to keep up during the final, the topic needs at least 16 partitions, even though analytics reads it happily with one.

Push or pull

Groups decide who reads an event. How does the event actually reach them? Like a doorbell against a letterbox, there are two ways. With push, the broker calls the subscriber as soon as an event arrives, usually through a webhook: an HTTPS endpoint the subscriber exposes for the broker to call. With pull, the subscriber asks the broker for a batch whenever it is ready.

PushPull
LatencyLow: delivered as it arrivesLow with long polling; higher with plain polling
Slow subscriberThe broker must back off and limit requests in flightHandled naturally: it pulls less often and falls behind
Subscriber needsA reachable endpoint that can take the loadOnly outbound connections to the broker
BatchingUsually one event per requestEasy: take 500 events per call
Good forWebhooks, serverless functions, low volumeHigh-volume streams, batch writers, analytics

Pull gives you back-pressure for free, which is why push tends to give way to pull as volume grows. Long polling narrows the latency gap: the broker holds a pull request open for up to, say, 20 seconds until something arrives.

http
POST /hooks/order-placed HTTP/1.1
Host: coupons.internal.example
Content-Type: application/json
X-Event-Id: evt_01HZX4T9
X-Signature: sha256=9c1f...e07a

{"type": "order.placed", "key": "ord_512", "data": {"user_id": 42, "total": 450}}

HTTP/1.1 204 No Content

A push delivery. Any 2xx reply counts as the ack; an error or a timeout means the broker retries later. The signature lets the subscriber reject events that did not come from the broker.

Push is simpler to consume, but the broker then needs rules for a subscriber that errors or slows down: exponential backoff, a cap on deliveries in flight, and a dead-letter destination after many failures.

Pub-sub to phones is a different system

A broker delivers to a few dozen server endpoints; the app has millions of devices, mostly offline. Phones are reached through platform push services such as APNs and FCM, or through connection servers holding WebSockets. The notifications and group chat chapters design those; the broker feeds them events from behind.

Retention and replay

How long does an event live after its subscribers have read it? In a classic pub-sub broker, it is removed from a subscription once that subscriber acks it. In a log-based broker, each event is stored once and stays for a retention period, such as 7 days, whether or not anyone has read it. A subscription's "own copy" is really its own position in one shared log, like a newspaper archive where each reader keeps a bookmark.

Keeping events unlocks replay. After fixing a bug in analytics, rewind its offset by a day and recompute. A brand-new fraud service can start a week back and build its own data from history, without asking another team for an export. Retention costs disk, so size it. Take the order-events topic during the final, carrying every status change of every order:

Retention for the order-events topicValue
Events per second2,000
Average event size1 KB
Per day2 MB/s × 86,400 s ≈ 173 GB
Kept for 7 days≈ 1.2 TB
With 3 replicas≈ 3.6 TB of disk

A week of replay for a busy topic costs a few TB of replicated disk, cheap next to rebuilding a service's data by hand.

For topics that describe the current state of something, such as a restaurant's opening hours, compaction is an alternative to time-based retention. The broker keeps at least the latest event for each key and discards older ones, so the topic stays small but a new reader can still rebuild the full current state.

Replay re-runs side effects

Replaying "order placed" through the coupon subscriber sends every dessert offer again. Before you rewind a group, make sure its consumer is idempotent, or that it only rebuilds data and sends nothing.

Part 3 · Designing Events and Workflows

Ordering and duplicates

Replays and rebalances mean a subscriber will see repeats. What else can arrive wrongly? The message queue chapter's rules hold: delivery is at least once, and order holds at best within one partition, so choose the key by what must stay in order. Pub-sub adds a trap of its own: there is no order across topics. "Payment captured" on the payments topic can reach a subscriber before "order placed" on the orders topic.

json
{
  "event_id": "evt_01HZX4T9",
  "type": "order.placed",
  "key": "ord_512",
  "version": 1,
  "occurred_at": "2026-10-18T20:41:03Z",
  "data": { "user_id": 42, "restaurant_id": 881, "total": 450 }
}

A unique id for deduplication, a key for ordering, and the order's version, which rises with every change to the order.

Each of the three problems has a cheap defence on the subscriber's side:

ProblemWhy it happensSubscriber's defence
Duplicate eventA lost ack, a crash, a rebalance or a publisher retryDedupe on event id; make writes idempotent
Older event after a newer oneA retry or a replayKeep the latest version per key; ignore anything older
Related event not seen yetDifferent topics have no shared orderPark the event and retry later, or ask the owner for the current state

The version check solves two rows in one line: "apply only if the event's version is greater than the stored version" drops both duplicates and stale events. Parking has a cost of its own: the subscriber needs a small store for parked events and a deadline, after which it asks the owner directly, so an event whose partner was lost does not wait forever.

Events are a contract

That event shape, with its id, key and version, now has readers you may never meet. What happens when you need to change it? Once ten services subscribe to order.placed, its shape is a public API. Renaming a field breaks every subscriber you forgot about, and with pub-sub you may not know who they all are. Four habits keep changes safe:

  • Name events as past-tense facts. order.placed and payment.failed describe what happened; place.order reads like a command.
  • Add, never remove or rename. New optional fields are safe; old subscribers ignore them.
  • Version breaking changes. Publish order.placed.v2 alongside v1 until every subscriber has moved over.
  • Validate when publishing. A schema registry, a shared store of event schemas, rejects a malformed event before it reaches anyone.

A second design choice is how much data an event carries:

Thin eventFat event
CarriesIds only: "order 512 changed"The data: items, total, address
The subscriber mustCall the owner for detailsNothing more
Load on the ownerA burst of calls after every eventNone
FreshnessAlways the latest stateThe state at the moment of publishing
Size and privacySmall, little to leakLarger; personal data spreads to every subscriber

Thin events turn every publish into a burst of reads on the owner, so most teams choose a middle ground: a fat event with only the fields subscribers need, leaving out personal details such as phone numbers unless a subscriber truly needs them.

Where pub-sub fits

With events designed well, where does pub-sub earn its place? The same order-placed events serve very different jobs:

Notifications

"Order out for delivery" event

Push, SMS and email services each react

Cache invalidation

"Menu 42 changed" event

Each region's cache deletes its copy

Event-driven services

Order placed, then paid, then accepted

Each service reacts to the step before

Analytics and search

The same events fill a warehouse

and keep a search index up to date

The cache invalidation case links back to the distributed cache chapter. Instead of every writer remembering to delete keys in every region, the menu owner publishes one change event, and a small invalidator in each region subscribes and deletes the matching keys. Keep a TTL as well, because an event can be late or lost.

Pub-sub is the wrong tool in three cases:

  • You need an answer back: a login check or a price quote is a request, not an event.
  • Exactly one service must do the job: use a queue, or a topic with a single group.
  • Two services only, for good: a direct call may be simpler.

The third card hides the hardest case. When placing an order needs billing, the restaurant and the order service each to commit their own step, what happens if a later step fails after an earlier one is done?

Workflows across services: sagas

On final night, order 512 spans three services, each with its own database: the order service records it, billing charges ₹450, and the restaurant accepts or rejects it. No single database transaction covers all three. A saga is the usual answer: a chain of local transactions, each committed in one service, each publishing an event that starts the next. It works like booking a trip: if the hotel falls through after the flight is paid, you cancel the flight for a refund; you cannot un-book it.

When a later step fails, the saga runs compensating actions: new transactions that undo the earlier steps' effects.

Animated sceneA saga that fails at the restaurant step0:20

Watch for the refund: a failed later step is undone by a new step, not a rollback

Read it as steps

Follow order 512 from charge to refund.

The customer was charged and then refunded: two real transactions, both on the statement. That is the price of a saga: its in-between states are visible, so design them on purpose, and make every compensation idempotent and retried until it succeeds. Order the steps so the likeliest failure comes first: asking the restaurant before charging, or holding the money and capturing it only after acceptance where the payment method allows, would have avoided this refund, at the cost of a few seconds' wait at checkout.

Who runs the saga

In the scene, no service was in charge: each reacted to the event before it. Is that the best way to run a saga? That style is choreography. It keeps services independent, but no single place shows where order 512 is stuck, and the flow exists only as a set of subscriptions spread across teams.

The alternative is orchestration: one coordinator, the orchestrator, sends each step and each compensation in turn and records where every order stands, with a timeout on each step. The advice flips with size. Two or three steps owned by different teams suit choreography. Past three or four steps, or when support staff need to answer "where is my order?", an orchestrator is usually easier to follow and to debug. The payments chapter runs a saga end to end, with a ledger, provider callbacks and reconciliation.

Why not one distributed transaction?

Two-phase commit (2PC) makes several databases commit as one: a coordinator asks every participant to prepare, which means lock the rows and promise to commit, then tells them all to commit. If the coordinator dies after the prepare, the participants must hold their locks and wait, because none may decide alone. Between services owned by different teams, and with a payment provider that has no prepare step at all, that blocking is rarely acceptable. 2PC stays inside one database or among a few built for it; between services, use a saga.

Either way, the saga's steps travel as events or commands through the broker, so everything earlier in this chapter applies to them: repeats, reordering and lag. The next part looks at what goes wrong when those problems meet production traffic.

Part 4 · Failures and the Interview

What goes wrong

Pub-sub moves problems around rather than removing them. Which failures should you plan for from the start? Each one below has a cheap defence if it is designed in early:

FailureWhat happensDefence
Slow subscriberIts backlog grows; the others are fineScale its group; alert on lag measured in time
Subscriber further behind than the retentionEvents are deleted before it reads themRetention longer than your worst outage; alert long before
Poison eventOne bad event blocks a partition for one groupLimited retries, then a dead-letter topic per subscription
Publisher cannot reach the brokerEvents are lost, or the user's request failsThe outbox pattern from the message queue chapter
Fan-out amplificationOne event becomes 20 deliveries, plus retriesLimit retries; give each subscriber its own rate limit
Broker node diesPartitions it led move to replicasReplicate each partition; confirm only once enough copies exist

Most rows come down to one habit: give each subscriber its own limits and its own alarms.

Two of these deserve numbers. If a subscriber can be down for a long weekend, 3 days, and you keep events for 2, it comes back to a hole in its data, so retention must exceed the longest outage you intend to recover from. And fan-out multiplies load: a burst of 5,000 events a second on a topic with 8 subscriptions is 40,000 deliveries a second.

Lag in seconds, not messages

Report each group's lag as the age of the oldest unread event. "Coupons are 40 minutes behind" is something an on-call engineer can act on; "72,000 messages behind" is not.

Names you will hear

SNS on AWS pushes each message to every subscriber; pair it with one SQS queue per subscriber for durable fan-out. Google Pub/Sub is a managed topic with push or pull subscriptions and per-message acks, with no partitions to manage. Kafka topics with consumer groups give per-key order, long retention and replay at very high volume, at the cost of sizing and running partitions.

In the interview

Pub-sub belongs in a design wherever one action triggers several independent reactions; draw the topic, name the subscriptions, and back each line with a number or a trade-off.

Say
"The order service publishes order-placed once; billing, coupons and analytics each have their own subscription, so a slow coupon service never delays checkout."
Say
"Billing runs 6 instances in one consumer group on a 24-partition topic, so each event reaches one instance, and the group can grow to 24."
Say
"We keep 7 days of events, longer than a long-weekend outage, so a broken subscriber catches up and a new one can replay a week."
Avoid
Publishing "charge this card" to a topic read by three groups: it charges three times. A command goes to one queue or one group.
Avoid
Assuming order-placed arrives before payment-captured: they are on different topics, so park the early event or ask the owner.

Wrap-up

These terms return in the notifications, news feed and payments chapters:

TermMeaning
TopicA named stream of one kind of event
SubscriptionA reader of a topic with its own progress
Consumer groupInstances of one service sharing a subscription's events
RebalanceMoving partitions between group members when one joins or leaves
ReplayRewinding a group's offset to process past events again
CompactionKeeping only the latest event for each key
SagaA workflow of local steps, undone by compensations on failure

Next, the Rate Limiters chapter caps how much any one client can send, so a single noisy caller cannot sink a shared system.

Part 5 · Check yourself

Quick check

Q1Why does keying events by order id not guarantee that "payment captured" arrives after "order placed"?

Think it through, then check.

Q2Customers sometimes get two dessert offers for one order. Why, and how do you fix it?

Think it through, then check.

Q3The coupon subscriber was down for 10 hours while events kept arriving at 2,000 a second. It can process 5,000 a second, and the topic keeps 3 days. How long does catching up take, and is anything lost?

Think it through, then check.

Q4A new recommendations service needs the last 30 days of orders, but the topic keeps only 7. How do you backfill it, and what do you trade off?

Think it through, then check.

Q5The distributed cache chapter kept cached data fresh with deletes and TTLs. Now a menu-changed event clears each region's cached menus. What happens if an event arrives twice, late, or never?

Think it through, then check.

Next chapter · 16 of 39Rate Limiters~33 min · 3 animated scenes