Skip to lesson
‹

Courses / System Design Interview / Chapter 14

Module 2 · Building Blocks · Chapter 14 of 39

Message Queues

17 sections · ~34 min✓ Fact-checked

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 callThrough a queue
Checkout waits forEvery downstream serviceOnly the write to the queue, a few ms
If the receipt service is slow or downCheckout slows, fails or hangsMessages wait; receipts go out when it recovers
A sudden spikeHits every service at full forceBuilds up in the queue and drains at a steady rate
Adding a new stepChange and redeploy the callerAdd a consumer; the producer does not change
Getting a result backImmediateNot 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.

Asynchronous means later, not never

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.

Animated sceneA sale spike: the queue fills, then drains0:21

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:

QuantityValueHow
Backlog after the first minute36,000(1,000 − 400) × 60 s
Longest wait in the rushabout 90 s36,000 ÷ 400 per second
Time to clear afterwardsabout 120 s36,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.

Reply "confirming", not "confirmed"

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.

The life of one message
  1. 1The producer sendsthe broker stores the message, copies it to replicas, then confirms
  2. 2A consumer receives itthe message is hidden from other consumers for 30 s
  3. 3The consumer does the workcheck stock, take payment, write the order
  4. 4The consumer acksthe broker deletes the message
  5. 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.

Set the timeout just above your slowest normal job

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:

GuaranteeHowOn a crashUse for
At-most-onceAck (and wait for it), then processThe message can be lost, never repeatedMetrics samples, location pings the next ping replaces
At-least-onceProcess, then ackNever lost, sometimes repeatedMost business work, with an idempotent consumer
Effectively-onceAt-least-once plus deduplication, or the result and the progress saved in one transactionRepeats arrive but change nothingPayments, orders, wallets
At-most-once

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

At-least-once

HowProcess, then ack

On a crashNever lost, sometimes repeated

Use forMost business work, with an idempotent consumer

Effectively-once

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.

When a product says "exactly once"

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.
Animated sceneA worker crashes before its ack0:21

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.
sql
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.

Keep dedupe records long enough

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:

OrderingHowThroughputExample
NoneAny consumer takes any messageHighestSending receipts
Per keyPartition by key; one consumer per partitionGrows with the number of partitionsAll events for one order or one wallet
TotalOne partition, one consumerOne consumer's speedA small ledger that must be strictly sequential
None

HowAny consumer takes any message

ThroughputHighest

ExampleSending receipts

Per key

HowPartition by key; one consumer per partition

ThroughputGrows with the number of partitions

ExampleAll events for one order or one wallet

Total

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:

FailureExampleWhat to do
TemporaryTimeout, HTTP 503, lock conflictRetry with backoff
PermanentInvalid data, HTTP 400, unknown product idDo not retry; dead-letter it at once
UnknownAn unexpected exceptionRetry 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.

python
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.

Retries in a log-based broker

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.

Watch and replay the dead-letter queue

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.

Watch the age, not just the count

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 forSpeedWhat can be lost
Nothing (fire and forget)FastestAnything in flight, silently
The leader's copyFastRecent messages if the leader dies before followers copy them
Enough copies, such as 2 of 3A few ms slowerNothing 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.

Keep messages small

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 queueLog
After a consumer acksThe message is deletedThe message stays until retention ends, such as 7 days
Consumer progressTracked per message by the brokerOne offset per partition, saved by each consumer group
Replaying old messagesUsually not possible once deletedMove the offset back
OrderingOften best effort; some keep strict order per queue or keyPer partition
Typical useTask distribution: send this receipt, resize this photoEvent 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".

Names you will hear

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:

One write, start to finish
  1. 1The producer sends a batch to the leaderand asks to hear back once enough replicas hold it
  2. 2The leader appends it to its logsay offsets 1,000 to 1,499
  3. 3Followers fetch and append the same batcheach reports how far it has copied
  4. 4The committed point moves past 1,499enough replicas, such as 2 of 3, now hold the batch
  5. 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.

Animated sceneA partition leader dies mid-write0:25

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:

TermMeaning
BrokerThe server that stores messages and hands them to consumers
AckA consumer's signal that a message is done and can be dropped
Visibility timeoutHow long a delivered message stays hidden before redelivery
Idempotent consumerA consumer for which a repeated message changes nothing more
PartitionAn ordered slice of a topic, read by one consumer per group
OffsetA message's position in a partition; groups save how far they read
Dead-letter queueWhere messages go after repeated failures, to be inspected and replayed
Committed pointThe 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.

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.

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.

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.

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.

Next chapter · 15 of 39Publish-Subscribe~27 min · 2 animated scenes