Courses / System Design Interview / Chapter 14
Module 2 · Building Blocks · Chapter 14 of 39
Message Queues
Builds on Abstractions That Hide the Network, Databases: Replication, Partitioning and Choosing a Store and Monitoring: Metrics, Logs, Traces and Alerts. Next up: Publish-Subscribe.
Part 1 · Why Put a Queue in the Middle
Direct calls tie services together
12:00 noon, and a phone sale opens on a shopping app. In the first minute, 60,000 buyers tap Pay. The order service calls the stock, payment, receipt and warehouse services in turn and waits for all four. The receipt service, sized for an ordinary day, slows to three seconds a call. Every checkout now waits on it, request threads run out, and buyers who were already charged see an error page. Nothing was wrong with orders or payments. The app needed to accept each order in milliseconds and do the slower work just after: a message queue.
Think of the token counter at a bank: you take a token and sit down, and clerks call the next number when free. In a message queue, the order service, the producer, writes a small message such as "order 881 placed" and moves on. Consumers, such as a receipt worker, read messages and do the work at their own pace. The broker is the server that stores the queue and hands messages out.
On a bad day the two designs behave very differently:
| Direct call | Through a queue | |
|---|---|---|
| Checkout waits for | Every downstream service | Only the write to the queue, a few ms |
| If the receipt service is slow or down | Checkout slows, fails or hangs | Messages wait; receipts go out when it recovers |
| A sudden spike | Hits every service at full force | Builds up in the queue and drains at a steady rate |
| Adding a new step | Change and redeploy the caller | Add a consumer; the producer does not change |
| Getting a result back | Immediate | Not built in: a status check, a callback or a reply message |
The queue frees checkout from every slower service; the last row is the price, because the result comes later.
A queue turns "do it now" into "do it soon", which suits receipts and reports. Call directly when the user waits for the answer, such as a login check, or when one more moving part costs more than it saves.
Absorbing a spike
The slow receipt service was one problem. What happens when orders arrive faster than the workers can finish them? In the sale's first minute, 1,000 orders a second arrive, and the workers that check stock and take payment finish about 400 a second. Without a queue, the other 600 a second time out. With one, they wait in line like token holders: the backlog grows by 600 a second, then shrinks by a net 300 a second once orders fall back to 100 a second.
Before noon, each order goes straight through and the queue stays near zero
Read it as steps
Watch the count on the queue during the 12 noon rush.
The queue turned a spike the workers could not handle into a delay they could. The last order of the rush waited about 90 seconds for confirmation, instead of 36,000 orders failing. A queue trades waiting time for not dropping work, so it fits jobs where a short delay is acceptable.
The arithmetic behind the scene fits in three lines:
| Quantity | Value | How |
|---|---|---|
| Backlog after the first minute | 36,000 | (1,000 − 400) × 60 s |
| Longest wait in the rush | about 90 s | 36,000 ÷ 400 per second |
| Time to clear afterwards | about 120 s | 36,000 ÷ (400 − 100) per second |
A backlog grows at the arrival rate minus the service rate and clears at the reverse, so a queue helps only when the spike ends.
The quick reply only means "we have your order". When stock is short, say 5,000 phones for 60,000 orders, reserve a unit first with one atomic conditional update: UPDATE stock SET available = available - 1 WHERE item_id = 7 AND available > 0. The 5,001st buyer hears "sold out" at once, not after 90 seconds. The ticket booking chapter builds this flow.
Part 2 · How Delivery Works
Acknowledgements and visibility timeouts
A queue that holds 36,000 orders must also survive its own workers. If worker 1 takes order 881 and crashes halfway through, the order must not vanish. So how does the broker know that a message is really done?
The way a courier does: it waits for a signature. Consumers acknowledge (ack) a message after finishing it, and only then does the broker delete it; a message never acked is delivered again. Workers sharing one queue each take different messages, a pattern called competing consumers, so adding capacity means starting another worker.
- 1The producer sendsthe broker stores the message, copies it to replicas, then confirms
- 2A consumer receives itthe message is hidden from other consumers for 30 s
- 3The consumer does the workcheck stock, take payment, write the order
- 4The consumer acksthe broker deletes the message
- 5No ack within 30 s?the message becomes visible again and is delivered once more, often to another consumer
That hiding period is the visibility timeout. Log-based brokers, which Part 4 opens up, keep messages after they are read: each consumer group, the set of workers sharing the reading of one stream, saves an offset, its position in the log, and resumes from it after a crash. Either way, work counts as done only when the consumer says so.
If a job sometimes takes 45 seconds and the timeout is 30, the message reappears while the first worker is busy, and a second worker runs it too. Too long costs as well: with 10 minutes, a crashed worker's order waits 10 minutes. Set it a little above the p99 processing time, or let the worker extend it as it works.
Three delivery guarantees
The ack closes one gap and opens another, because the moment you ack decides what a crash costs. Ack before the work and a crash loses the message. Ack after the work and a crash between finishing and acking brings the message back, so the work runs twice. Each choice has a name:
| Guarantee | How | On a crash | Use for |
|---|---|---|---|
| At-most-once | Ack (and wait for it), then process | The message can be lost, never repeated | Metrics samples, location pings the next ping replaces |
| At-least-once | Process, then ack | Never lost, sometimes repeated | Most business work, with an idempotent consumer |
| Effectively-once | At-least-once plus deduplication, or the result and the progress saved in one transaction | Repeats arrive but change nothing | Payments, orders, wallets |
HowAck (and wait for it), then process
On a crashThe message can be lost, never repeated
Use forMetrics samples, location pings the next ping replaces
HowProcess, then ack
On a crashNever lost, sometimes repeated
Use forMost business work, with an idempotent consumer
HowAt-least-once plus deduplication, or the result and the progress saved in one transaction
On a crashRepeats arrive but change nothing
Use forPayments, orders, wallets
Most business work picks the middle row and then upgrades it to the last, because a lost order costs far more than a repeat you can make harmless.
Why not deliver exactly once? When no ack arrives, the broker cannot tell whether the work never happened, or happened and then the consumer died or the ack was lost. The cases look identical, so the only safe move is to deliver again. What you can build is an exactly-once effect: deliver at least once, and make the second delivery do nothing. The next page shows one.
Check what it covers: usually writes that stay inside the broker, or dropping duplicates sent within a time window. It cannot stop your consumer from charging a card twice.
A worker crashes before its ack
Here is the classic case. Worker A takes order 881, charges the buyer ₹450 and crashes before it can ack. It passed the order id as an idempotency key, the abstractions chapter's tool for safe retries: the payment service remembers each key and the result it gave.
Predict before you watch: what happens to order 881 after the 30-second visibility timeout, and is the buyer charged twice?
- The message becomes visible again and goes to worker B, which sends the charge again.
- No second charge: the payment service has seen key 881 and returns the first result.
Order 881 is queued. Worker A takes it and charges the card
Read it as steps
Follow order 881: delivered twice, charged once.
The queue did its half: a message whose worker died was not lost. The payment service did the other half: it remembered key 881 and refused to charge twice. At-least-once delivery is only safe with idempotent consumers, so design for the repeat from the start, not after the first double charge.
Making consumers idempotent
The payment service in the scene was already idempotent. Your own consumers usually are not, so how do you get them there? An operation is idempotent if doing it twice has the same effect as doing it once, like pressing a lift button twice: one lift comes. "Set order 881's status to shipped" is idempotent. "Add the sale's ₹50 cashback to the wallet" is not: run it twice and the buyer gets ₹100.
Four techniques cover most consumers:
- Record processed ids. Insert each message id into a table in the same transaction as the work. A repeat fails on the unique key and changes nothing.
- Use conditional updates.
UPDATE orders SET status = 'paid' WHERE id = 881 AND status = 'pending'does nothing the second time. - Pass an idempotency key downstream, as in the scene: the only option when the work happens in another company's system.
- Send absolute values, not changes. "Stock for item 7 is 41, version 12" survives repeats and stale copies; "stock minus 1" does not.
BEGIN; -- fails on the primary key if msg-881 was already handled INSERT INTO processed_messages (message_id) VALUES ('msg-881'); UPDATE wallets SET balance = balance + 50 WHERE user_id = 42; COMMIT;
Both statements commit together or not at all. The consumer treats the duplicate-key error as already done and acks.
The first technique works only when the dedupe table and the data share one database; split across two, a crash between the writes brings the double credit back.
A repeat can arrive for as long as the broker may redeliver, plus any retries and replays. If messages can be retried for 4 days and replays reach back 7, keep processed ids for at least 7 days, then expire them so the table does not grow forever.
Part 3 · Ordering, Retries and Scale
Ordering and what it costs
Idempotency makes repeats harmless. Order is a separate problem: what if two messages about one order finish in the wrong sequence? Many high-volume queues keep order only roughly, and with ten consumers in parallel, "order 881 cancelled" might finish before "order 881 placed", so the cancel finds nothing to cancel.
Picture railway reservation counters: each serves its own line in order, the lines move independently, and a family always queues at the same counter. One total order for everything means one counter, so throughput is one consumer's speed. The usual middle ground is per-key order. The queue is split into partitions, every message carries a key such as the order id, and all messages with one key go to one partition, read by one consumer at a time. Log-based brokers call such a partitioned stream a topic.
With partition = hash(order_id) % 12, up to 12 consumers work in parallel, and every event for order 881 stays in the order the broker received it. The three choices compare like this:
| Ordering | How | Throughput | Example |
|---|---|---|---|
| None | Any consumer takes any message | Highest | Sending receipts |
| Per key | Partition by key; one consumer per partition | Grows with the number of partitions | All events for one order or one wallet |
| Total | One partition, one consumer | One consumer's speed | A small ledger that must be strictly sequential |
HowAny consumer takes any message
ThroughputHighest
ExampleSending receipts
HowPartition by key; one consumer per partition
ThroughputGrows with the number of partitions
ExampleAll events for one order or one wallet
HowOne partition, one consumer
ThroughputOne consumer's speed
ExampleA small ledger that must be strictly sequential
Ask for the weakest order that keeps the data correct: receipts need none, so a plain queue with competing consumers scales with no partitions to plan.
Per-key order has costs. A slow or failing message blocks every message behind it in its partition: head-of-line blocking. A very busy key, such as one seller with half the sale's orders, makes its partition hot. And retries must not jump the line: skip a failed message 5 to run message 6, and the order is already broken.
Retries and dead-letter queues
Head-of-line blocking turns one failing message into a stalled partition, so how should a consumer retry? Some failures fix themselves: the payment gateway times out during the sale, and a retry seconds later works. Others never will: an order with a missing address fails every time. That is a poison message. Sort failures into three kinds:
| Failure | Example | What to do |
|---|---|---|
| Temporary | Timeout, HTTP 503, lock conflict | Retry with backoff |
| Permanent | Invalid data, HTTP 400, unknown product id | Do not retry; dead-letter it at once |
| Unknown | An unexpected exception | Retry a few times, then dead-letter |
Retrying a permanent failure only burns workers, so the first question is always which row you are in.
Retries should wait longer each time, exponential backoff, plus randomness, jitter, so a thousand failed orders do not retry in the same second. After, say, 5 attempts, the message moves to a dead-letter queue (DLQ), named after the post office's dead letter office: a separate queue where failures wait to be inspected, fixed and replayed.
import random def retry_delay(attempt, base=1.0, cap=300.0): # attempt 1: up to 1 s, attempt 2: up to 2 s, attempt 3: up to 4 s ... at most 5 min delay = min(cap, base * 2 ** (attempt - 1)) return delay / 2 + random.uniform(0, delay / 2) # jitter
With jitter, retries spread over a window instead of arriving as one wave.
A log has no per-message ack, so retrying in place stalls the partition. Instead, publish the failure to a delay topic such as orders-retry-1m, whose consumer handles it once it is a minute old, then orders-retry-10m, then a dead-letter topic. The retried message now runs after later messages for its key; when order matters, park the key, sending its later messages down the same path.
Every message in it is an order that did not happen. Alert when it is not empty. Replay after fixing the cause, at a throttled rate; idempotent consumers absorb messages that half-succeeded.
Back-pressure and scaling consumers
Retries handle a few bad messages. What if every message is slow, for hours? If producers stay faster than consumers, the backlog grows until disks fill or the work is too old to matter: a confirmation an hour late has already lost the buyer. Either drain faster or take in less. Back-pressure is the second, like a restaurant that stops taking names when its waiting list is full: a full system signals upstream, so producers slow down or tell users to try again. Four levers help:
- Add consumers. Autoscale on the age of the oldest message, not on CPU.
- Bound the queue. When it is full, reject new messages, so the app can say "try again shortly" now instead of failing silently an hour later.
- Expire stale work. Give messages a time to live, so an hour-old message is dropped rather than acted on.
- Shed low-value work. Sample analytics events during the sale and keep the orders.
More consumers help only up to a point. A consumer group uses at most one consumer per partition, and workers writing to a database that takes 5,000 writes a second go no faster, however many you add. Say each order worker handles 50 messages a second and the sale needs 2,000: that is 40 workers. With 32 partitions only 32 get work, so throughput tops out at 1,600 a second. Choose 64, because raising the count later moves keys to new partitions and can break per-key order during the change.
A backlog of 10,000 means nothing on its own: it might clear in 2 seconds or 2 hours. The age of the oldest message says how late the work is, so alert on that. The monitoring chapter covers alerting on symptoms.
Part 4 · Inside the Broker
Keeping messages safe
A backlog of 36,000 orders is useful only if a broker restart does not wipe it, so how does the broker keep a confirmed message? Like a bank writing each entry into two ledgers, a durable broker stores each message on disk, copies it to other broker nodes, and confirms only once enough copies exist. With 3 copies and a rule that 2 must hold the message before the producer hears "ok", any one broker can die without losing a confirmed message, the replication idea from the databases chapter.
The producer chooses how long to wait, and each choice loses something different:
| Producer waits for | Speed | What can be lost |
|---|---|---|
| Nothing (fire and forget) | Fastest | Anything in flight, silently |
| The leader's copy | Fast | Recent messages if the leader dies before followers copy them |
| Enough copies, such as 2 of 3 | A few ms slower | Nothing confirmed, unless both copies fail at once |
For orders those few milliseconds are cheap; fire and forget suits only data you can lose, such as metrics.
The producer side has its own trap, the dual write. The order service saves order 881 to its database, then crashes before publishing the message, or publishes and then fails to save. Either way the two disagree. The outbox pattern fixes it: write the message into an outbox table in the same transaction as the order, and let a separate relay publish from the outbox, retrying until the broker confirms. Delivery is at least once again, so consumers still dedupe.
Brokers are built for many small messages, usually a few KB each, and most cap the size. To process a 200 MB video, store the file in a blob store and put only its key in the message.
Inside a log-based broker
A classic queue deletes a message once it is acked. A broker that takes hundreds of thousands of messages a second and serves several services is usually built differently: around an append-only log. Think of a shop's ledger: entries are only added at the end, and each reader keeps a bookmark. How does that change the broker?
| Classic queue | Log | |
|---|---|---|
| After a consumer acks | The message is deleted | The message stays until retention ends, such as 7 days |
| Consumer progress | Tracked per message by the broker | One offset per partition, saved by each consumer group |
| Replaying old messages | Usually not possible once deleted | Move the offset back |
| Ordering | Often best effort; some keep strict order per queue or key | Per partition |
| Typical use | Task distribution: send this receipt, resize this photo | Event streams read by several services |
Pick a classic queue for jobs that one worker does once; pick a log when several services read the same stream or you may need to replay it.
In a log, each partition is a series of segment files, say 1 GB each. New messages are only appended to the newest segment, and each gets the next offset. A small index per segment maps offsets to byte positions, and another maps timestamps, so a consumer can jump to offset 5,000,000 or to "yesterday 9 pm" without reading the whole partition.
Retention works on whole segments: when the newest message in a segment is older than the retention period, or the partition passes its size limit, the broker deletes that file in one cheap step. So a log cannot delete one bad message from the middle; consumers skip it or move it to a retry topic. A log keeps messages for everyone and lets each reader move its own bookmark, which is what makes replay and many independent readers possible.
Why a log is fast
Keeping every message for days sounds expensive. Why can one partition still take about 10 MB a second? Because a log uses the access patterns disks handle best:
- Sequential writes. Appending to the end of a file is far faster than writing to scattered places on disk.
- Batches. Producers send hundreds of messages per request, often compressed together, so one request and one disk write carry them all.
- The page cache. Consumers that keep up read recent messages from the memory the operating system uses for recently used file data, so they rarely touch the disk.
- No unpacking. The broker stores each batch as it arrived and sends it on in the same form.
That rough figure gives a second rule for counting partitions: the byte rate sets one floor and the number of parallel consumers another; take the larger. The sale's order topic carries 2,000 orders a second at about 2 KB each, only 4 MB a second, so bytes need a single partition, but 40 workers need at least 40 partitions. A clickstream topic is the opposite: millions of small events, where bytes decide. Each partition also costs files, memory and replication traffic, so size for need plus headroom, not thousands "to be safe".
Kafka and Pulsar are log-based, for high-volume streams that several services read and replay. Kinesis is a managed log on AWS, with shards for partitions. RabbitMQ is a classic broker with per-message acks and flexible routing, for task queues. SQS is a managed AWS queue with visibility timeouts and dead-letter queues built in.
Leaders, followers and the committed point
Speed is half the story; the other half is surviving a broker's death. Each partition is copied to several brokers, usually 3. Producers write to the leader and consumers read from it; followers fetch new messages from it and append them in the same order. One write goes like this:
- 1The producer sends a batch to the leaderand asks to hear back once enough replicas hold it
- 2The leader appends it to its logsay offsets 1,000 to 1,499
- 3Followers fetch and append the same batcheach reports how far it has copied
- 4The committed point moves past 1,499enough replicas, such as 2 of 3, now hold the batch
- 5The producer hears ok, and consumers may read ita leader crash can no longer lose these messages
The committed point, which some brokers call the high-water mark, is the offset up to which enough replicas hold the data. What counts as enough depends on the broker: a majority in some, every replica that is keeping up in others. Consumers are only given messages below the committed point, so they never act on a message that a leader crash could still take back.
Consumer progress lives in the broker too. Each group commits its offsets to a small internal topic, and after a crash or a rebalance the new owner of a partition resumes from the last committed offset. Commit after processing, and a crash repeats some work: at-least-once again, seen from the inside.
A controller decides which broker leads each partition, and is itself chosen by a small consensus group, as the coordination chapter explains. The next page asks what happens when a leader dies holding messages no follower has yet.
When a partition leader dies
During the sale, broker 1 leads the partition that holds order 881. It has just confirmed offsets up to 1,499 when the next batch, offsets 1,500 to 1,999, reaches it and nobody else. Then it crashes. Every change of leader raises a number, the leader epoch, from 7 to 8 here; brokers reject anything stamped with an older epoch.
Watch offsets 1,500 to 1,999: only the leader holds them when it crashes
Read it as steps
Follow offsets 1,500 to 1,999 through a leader crash.
Broker 1 had appended the second batch, but no follower held it, so it was never committed: the producer got no ok and the consumer could not read it. The producer's retry put the batch safely on the new leader, and the epoch told the returning broker exactly where its log parted from the leader's. Only what enough replicas hold counts as written, and that rule is what makes failover lose nothing confirmed.
Had broker 2 copied the batch before broker 1 died without replying, the retry would add a duplicate. Brokers with idempotent producers number each producer's batches, so the new leader drops the repeat. The price of this safety is latency: every confirmed write waits for a follower, a few milliseconds inside one datacenter.
In the interview
When a design needs a queue, say what it absorbs, what it guarantees and what happens when a consumer or a broker dies, with a number or a trade-off in each line.
- Say
- "Checkout waits only for a few-millisecond write to the queue, so a receipt outage delays receipts by minutes but never fails an order."
- Say
- "Delivery is at least once, so the payment worker passes the order id as an idempotency key, and we keep dedupe records for 7 days, past any redelivery or replay."
- Say
- "64 partitions keyed by order id keep each order's events in sequence and allow 64 parallel workers; we start with headroom because adding partitions later remaps keys."
- Avoid
- "We add a queue so nothing fails." A queue moves failure around: say what happens to the backlog when consumers stop for an hour.
- Avoid
- "The broker gives us exactly-once delivery." It cannot tell a lost ack from undone work; promise at-least-once plus idempotent consumers.
Wrap-up
These terms come back in almost every product chapter:
| Term | Meaning |
|---|---|
| Broker | The server that stores messages and hands them to consumers |
| Ack | A consumer's signal that a message is done and can be dropped |
| Visibility timeout | How long a delivered message stays hidden before redelivery |
| Idempotent consumer | A consumer for which a repeated message changes nothing more |
| Partition | An ordered slice of a topic, read by one consumer per group |
| Offset | A message's position in a partition; groups save how far they read |
| Dead-letter queue | Where messages go after repeated failures, to be inspected and replayed |
| Committed point | The offset up to which enough replicas hold the data |
Every queue here gave each message to one consumer. Next, the Publish-Subscribe chapter gives one event to every service that cares, each with its own copy.
Part 5 · Check yourself
Quick check
Q1Why can a message be processed twice even when the queue and the consumer have no bugs?
Think it through, then check.
- The consumer can finish the work and crash before its ack reaches the broker, or the ack itself can be lost.
- The work can also outlast the visibility timeout.
- In each case the broker redelivers, so the consumer must be idempotent.
Q2Lag on the sale's orders topic climbs every evening, yet the workers' CPU sits at 30%. What could cause it, and how do you find out which?
Think it through, then check.
- Check lag per partition first. One lagging partition points to a hot key, or a poison message retried in place.
- Even lag everywhere points downstream: a database or API at its limit keeps workers waiting, so CPU stays low.
- Also look for idle consumers beyond the partition count.
Q3During the sale, the clickstream topic takes 300,000 events a second at 1 KB each. A partition handles about 10 MB/s and a consumer about 20,000 events a second. How many partitions, and which limit decides?
Think it through, then check.
- Bytes: 300,000 × 1 KB = 300 MB/s, so at least 30 partitions.
- Consumers: 300,000 ÷ 20,000 = 15, so bytes decide.
- Add headroom for growth and for catching up after an outage: about 48 to 64.
Q4Sale cashback credits must apply to each wallet in order. One wallet's credit keeps failing because the wallet service times out for its bank. Do you block the partition or move the credit to a retry topic?
Think it through, then check.
- Blocking keeps that wallet's order, but every other wallet on the partition waits behind one failure.
- A retry topic keeps the partition moving; park the wallet so its later credits follow the same path.
- If every credit fails, the dependency is down for everyone: pause the consumer with backoff and let the backlog wait in the log.
Q5The abstractions chapter made a retried payment safe with an idempotency key. How does the same idea make "add ₹50 cashback" safe under at-least-once delivery?
Think it through, then check.
- Give each cashback a unique id and insert it into a processed table in the same transaction as the balance update.
- A redelivery fails on the unique key, so the balance changes once; the consumer treats the error as done and acks.
- Keep the ids as long as redelivery or replay can reach.