Courses / System Design Interview / Chapter 15
Module 2 · Building Blocks · Chapter 15 of 39
Publish-Subscribe
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:
| Term | Meaning | In the example |
|---|---|---|
| Event | A record of something that happened | Order 512 was placed at 20:41:03 |
| Topic | A named stream of one kind of event | order-placed |
| Publisher | A service that writes events to a topic | The order service |
| Subscription | A named reader of a topic with its own progress | Billing, coupons, analytics |
| Subscriber | The service, or its instances, behind a subscription | The 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.
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 queue | Pub-sub topic | |
|---|---|---|
| Each message reaches | One consumer, whichever takes it first | Every subscription |
| The sender knows | Which queue does the work | Only the topic name |
| Typical message | A command: "send this receipt", "resize this photo" | An event: "order placed", "user signed up" |
| Adding a new reader | It competes for the same messages | Add a subscription; nobody else changes |
| After a message is handled | Deleted | Removed 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:
| Instance | Partitions |
|---|---|
| billing-1 | 0, 1 |
| billing-2 | 2, 3 |
| Instance | Partitions |
|---|---|
| coupons-1 | 0 |
| coupons-2 | 1 |
| coupons-3 | 2 |
| coupons-4 | 3 |
| Instance | Partitions |
|---|---|
| analytics-1 | 0, 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.
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.
| Push | Pull | |
|---|---|---|
| Latency | Low: delivered as it arrives | Low with long polling; higher with plain polling |
| Slow subscriber | The broker must back off and limit requests in flight | Handled naturally: it pulls less often and falls behind |
| Subscriber needs | A reachable endpoint that can take the load | Only outbound connections to the broker |
| Batching | Usually one event per request | Easy: take 500 events per call |
| Good for | Webhooks, serverless functions, low volume | High-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.
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.
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 topic | Value |
|---|---|
| Events per second | 2,000 |
| Average event size | 1 KB |
| Per day | 2 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.
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.
{
"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:
| Problem | Why it happens | Subscriber's defence |
|---|---|---|
| Duplicate event | A lost ack, a crash, a rebalance or a publisher retry | Dedupe on event id; make writes idempotent |
| Older event after a newer one | A retry or a replay | Keep the latest version per key; ignore anything older |
| Related event not seen yet | Different topics have no shared order | Park 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.placedandpayment.faileddescribe what happened;place.orderreads like a command. - Add, never remove or rename. New optional fields are safe; old subscribers ignore them.
- Version breaking changes. Publish
order.placed.v2alongside 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 event | Fat event | |
|---|---|---|
| Carries | Ids only: "order 512 changed" | The data: items, total, address |
| The subscriber must | Call the owner for details | Nothing more |
| Load on the owner | A burst of calls after every event | None |
| Freshness | Always the latest state | The state at the moment of publishing |
| Size and privacy | Small, little to leak | Larger; 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:
"Order out for delivery" event
Push, SMS and email services each react
"Menu 42 changed" event
Each region's cache deletes its copy
Order placed, then paid, then accepted
Each service reacts to the step before
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.
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.
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:
| Failure | What happens | Defence |
|---|---|---|
| Slow subscriber | Its backlog grows; the others are fine | Scale its group; alert on lag measured in time |
| Subscriber further behind than the retention | Events are deleted before it reads them | Retention longer than your worst outage; alert long before |
| Poison event | One bad event blocks a partition for one group | Limited retries, then a dead-letter topic per subscription |
| Publisher cannot reach the broker | Events are lost, or the user's request fails | The outbox pattern from the message queue chapter |
| Fan-out amplification | One event becomes 20 deliveries, plus retries | Limit retries; give each subscriber its own rate limit |
| Broker node dies | Partitions it led move to replicas | Replicate 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.
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.
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:
| Term | Meaning |
|---|---|
| Topic | A named stream of one kind of event |
| Subscription | A reader of a topic with its own progress |
| Consumer group | Instances of one service sharing a subscription's events |
| Rebalance | Moving partitions between group members when one joins or leaves |
| Replay | Rewinding a group's offset to process past events again |
| Compaction | Keeping only the latest event for each key |
| Saga | A 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.
- Ordering holds only within one partition of one topic.
- The two events are on different topics, so nothing orders them against each other.
- The subscriber must park the early event and retry, or check the current state with the owning service.
Q2Customers sometimes get two dessert offers for one order. Why, and how do you fix it?
Think it through, then check.
- Delivery is at least once: a crash, a lost ack or a rebalance redelivers the event.
- Check a store of handled event ids before sending, and record the id right after a successful send.
- A crash between sending and recording can still repeat one; passing the event id to the messaging provider as an idempotency key, if it accepts one, closes that gap.
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.
- Backlog: 2,000 × 36,000 s = 72 M events.
- It gains 5,000 − 2,000 = 3,000 a second, so it catches up in 24,000 s, about 7 hours.
- Nothing is lost: the oldest unread event is 10 hours old, well inside 3 days.
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.
- Replay cannot reach past retention, so load older history from the source of truth: a database snapshot, or an archive of events in a warehouse or blob store.
- Start the new group from offsets a little before the snapshot's time, and upsert by order id and version so the overlap does no harm.
- Throttle the backfill or read from a replica, so it does not slow the live database.
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.
- Twice: harmless, because deleting a key is idempotent.
- Late: readers see the old menu until it arrives, so alert on the invalidator's lag.
- Never: keep a TTL, such as 10 minutes, as a safety net, so a lost event cannot leave a stale menu forever.