Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Intermediate/I3
10 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDI3 · Part 2 · Intermediate

Message queues & async processing

The request path should only do the work the user is actually waiting for — everything else goes on a queue and gets retried.

20 min read3 diagrams9 sections0 exercises
10 of 36

In this chapter

  • Decoupling: what you actually buy
  • Queue vs log: RabbitMQ and Kafka are not the same shape
  • Delivery semantics, done properly
  • Retries, dead letters and poison messages
  • Durability and the other knobs worth naming

and 4 more

about 20 min
09On this page
  1. Decoupling: what you actually buy
  2. Queue vs log: RabbitMQ and Kafka are not the same shape
  3. Delivery semantics, done properly
  4. Retries, dead letters and poison messages
  5. Durability and the other knobs worth naming
  6. Backpressure: the queue is a shock absorber, not a landfill
  7. Ordering: per-key is almost always enough
  8. The outbox pattern: fixing the dual-write problem
  9. Recognizing it in an unseen problem

Decoupling: what you actually buy

Synchronous calls couple two things that have no business being coupled: the latency a user experiences and the capacity of the slowest downstream service. Put a durable buffer between producer and consumer and you break that link. Four distinct wins come out of it, and naming them separately is what makes you sound like you have run this in production.

  • Spike absorption. A queue turns a 20x traffic burst into a growing backlog instead of a cascade of timeouts. Your workers keep running at their sustainable rate; the queue eats the difference. Latency degrades gracefully rather than the system falling over.
  • Latency shedding. Image transcoding takes 4 s; sending an email takes 800 ms; reindexing takes 300 ms. None of it needs to happen before the HTTP response. Enqueue in ~2 ms and return 202.
  • Retries with durability. If the email provider is down, an in-process retry loop dies with the pod. A queued message survives the deploy, the crash and the region failover.
  • Fan-out. One "order placed" event feeds billing, search indexing, the recommendation model, the fraud pipeline and the data warehouse — and adding a seventh consumer needs no change to the producer.
POST /upload synchronous thumbnail 4 s email 800 ms index 300 ms user waits 5.1 s; one slow provider makes every upload time out POST /upload enqueue, 2 ms durable queue worker pool thumbnailer mailer 202 Accepted in 12 ms; a dead mail provider grows a backlog instead of an outage
The queue does not make the work faster. It makes the work not the user's problem, and it makes failure recoverable instead of lost.
⚠ Async changes the product, not just the architecture The moment you return 202 you owe the user a way to observe completion: a status endpoint, a websocket push, an email, or an optimistic UI that reconciles later. Candidates who move work off the request path without saying how the client learns it finished get marked down — the interviewer is checking whether you understand you just introduced a distributed state machine into the product.

Queue vs log: RabbitMQ and Kafka are not the same shape

A queue broker (RabbitMQ, SQS, ActiveMQ) tracks per-message state. A message is delivered, acknowledged, and deleted. The broker owns the bookkeeping, which buys you per-message acking, arbitrary redelivery delays and routing topologies — at the cost of holding mutable state per message.

A log (Kafka, Pulsar, Kinesis, Redpanda) is an append-only file per partition. Consumers hold an offset — a number — and the broker deletes nothing until the retention window expires. Reads and writes are mostly sequential and lean on the operating system's page cache, which is why a single broker can sustain hundreds of MB/s. Because messages aren't deleted on read, you can rewind the offset and replay a week of history into a new consumer, which is the capability classic queues lack. The two shapes are converging: RabbitMQ Streams add a replicated, non-destructive log (with offset and timestamp replay) inside RabbitMQ, and Kafka 4.2 made share groups ("queues for Kafka", KIP-932) production-ready, giving per-record acknowledgement on top of a log. Pick by the semantics you need, not by the product name.

producer key = userId hash % 3 partition 0 partition 1 partition 2 consumer A owns P0 consumer B owns P1 consumer C owns P2 consumer D idle — no partition one partition has exactly one owner in a group, so partition count is your parallelism ceiling
Consumer D is the whole lesson: in a classic Kafka consumer group you scale by adding partitions, not by adding pods, because a fourth consumer on three partitions has nothing to read. (Assignors balance partitions to within one of each other, so with three consumers each would own one.) Pick the partition count for the parallelism you'll want in a year, within the cost limits described below.
 Queue broker (RabbitMQ queues, SQS)Log (Kafka, Pulsar, RabbitMQ Streams)
Unit of progressPer-message ack / nackPer-partition offset commit
After a message is consumedDeletedStill there until retention expires (hours to forever)
ReplayNot in a classic or quorum queue — you must have kept a copy yourself (RabbitMQ Streams are the log-shaped exception)Seek the offset backwards (or to a timestamp); a brand-new consumer can read all history
OrderingPer queue while one consumer reads it; lost once competing consumers share a RabbitMQ queue (SQS FIFO keeps order within a message group)Total order within a partition; none across partitions
ParallelismAdd consumers freely — the broker load-balances messagesCapped at partition count per consumer group. Exceptions that trade away per-key order: Kafka 4.2 share groups and Pulsar shared subscriptions allow more consumers than partitions
Throughput, single nodeTens of thousands of msg/s per node is a common planning figure for RabbitMQ, far lower with persistence, confirms and rich routing; benchmark your ownHundreds of MB/s; ~10 MB/s per partition is a comfortable planning figure
RoutingRich: topic/fanout/header exchanges, per-message TTL, delayed deliveryDeliberately dumb: topic + partition. Routing is the consumer's job
Reach for this whenTask/job semantics — "someone do this one thing", varied per-message delays, complex routing, modest volumeEvent-stream semantics — many independent consumers of the same events, replay for backfills or new services, ordered per-entity change streams, high volume

A cheap tell that you've thought about it: "SQS standard gives me nearly unlimited throughput but no ordering. SQS FIFO orders messages within a message group; by default each queue partition is limited to 300 API calls per second per action (3,000 messages/s with 10-message batches), and high throughput mode raises that to as much as 70,000 TPS (700,000 messages/s batched) in us-east-1, us-west-2 and eu-west-1, scaling with the number of distinct message groups. So I'd use a high-cardinality group id such as the user id, never one global group." Named limits beat adjectives, and the limits are per queue partition, not per group (check the current quota page).

Delivery semantics, done properly

Every distributed queue makes the same unavoidable choice: when the consumer processes a message and then dies before acknowledging it, was the message delivered? You can only pick which side of that ambiguity you fail on.

SemanticMechanismFailure modeUse it for
At-most-onceAck (or commit the offset) before processingCrash after ack, before work → message silently lostHigh-volume telemetry, click logs, metrics — where one lost sample is genuinely irrelevant and throughput is everything
At-least-onceAck after processing succeedsCrash after work, before ack → message redelivered and processed twiceEssentially everything. This is the default in Kafka, SQS and RabbitMQ, and the sane baseline
"Exactly-once"At-least-once delivery + an idempotent consumer, or a transaction spanning input and outputOnly holds inside the transactional boundary — the moment you call an external API it degrades to at-least-oncePayments, ledgers, anything where a duplicate is a customer-visible error
Exactly-once delivery does not exist; exactly-once effect does Two processes, an unreliable network, and no way to distinguish "your ack was lost" from "you never received it" — the sender must either resend (risking a duplicate) or not (risking a loss). This is a proof, not an engineering limitation. What you can build is an at-least-once pipeline whose side effects are idempotent, so processing a message twice produces the same state as processing it once. Kafka's exactly-once mode is exactly this, mechanised: an idempotent producer that de-duplicates by sequence number, plus a transaction that commits output records and input offsets atomically — and it only holds while you stay inside Kafka.

So the real engineering question is never "which semantic do I pick?" It is "what makes my consumer idempotent?" Three answers, in descending order of how often they're the right one:

  • A natural idempotency key. The message carries a stable ID (order ID, event ID). The consumer writes INSERT ... ON CONFLICT DO NOTHING into a processed-events table inside the same transaction as its business write. Second delivery hits the conflict and does nothing. Cheap, obvious, correct.
  • Naturally idempotent operations. SET status = 'shipped' is safe to repeat; balance = balance + 10 is not. Prefer absolute writes over deltas when you get to choose the schema — this single habit removes most duplicate-processing bugs before they exist.
  • Idempotency tokens at the boundary. When the side effect leaves your system (Stripe charge, email send), pass a client-generated key that the provider de-duplicates on. Stripe's Idempotency-Key exists for precisely this scenario (the header is described in the IETF draft draft-ietf-httpapi-idempotency-key-header, which is an Internet-Draft, last revised in October 2025 and not an RFC).
Say it like this → "I'll run at-least-once and make the consumer idempotent, because exactly-once delivery isn't achievable across a network — it's at-least-once plus de-duplication. I'll put a unique event ID on every message and have the consumer insert it into a processed-events table in the same transaction as the business write, so a redelivery is a no-op rather than a double charge."

Retries, dead letters and poison messages

At-least-once means redelivery, and redelivery means you need a policy for messages that will never succeed. A poison message — malformed payload, a referenced row that was deleted, a bug that only triggers on one record — will otherwise be retried forever, and in a Kafka partition it blocks every message behind it. That is head-of-line blocking, and it is how one bad record stops a pipeline for an entire key range.

  • Bound the retries. 3-5 attempts with exponential backoff and jitter (see the rate-limiting chapter — the same backoff maths applies). Unbounded retry against a struggling downstream is a self-inflicted DDoS, and synchronised retries are worse than no retries.
  • Distinguish retryable from terminal. A 503 or a connection reset deserves a retry. A 400, a schema violation or a foreign-key error will fail identically at attempt 50 — route it straight to the DLQ and skip the backoff ladder entirely.
  • Dead-letter queue. After the budget is exhausted, move the message — with the original payload, the error, the attempt count and a trace ID — to a DLQ. Then alert on DLQ depth. An unmonitored DLQ is a data-loss mechanism with extra steps; the DLQ's value is entirely in someone looking at it.
  • Make the DLQ replayable. Fix the bug, ship it, drain the DLQ back into the main queue. If replay requires a bespoke script written under incident pressure, you don't have a DLQ, you have a graveyard.
  • In Kafka, sidestep head-of-line blocking by publishing the failure to a retry topic and committing the offset, so the partition keeps moving. You trade strict ordering for liveness, deliberately.

Durability and the other knobs worth naming

A queue is only as durable as its replication settings. For Kafka, the usual production shape is replication factor 3, producers on acks=all and min.insync.replicas=2: a write is acknowledged once the in-sync replicas have it, and the partition refuses writes rather than run on a single copy, trading availability for durability. Kafka guarantees a committed message survives as long as one in-sync replica stays alive. Other features that come up: log compaction (keep at least the latest record per key, with tombstones for deletes, which is how a topic becomes a table changelog); visibility timeout in SQS (a received message is hidden for 30 seconds by default, up to 12 hours; if the consumer does not delete it in time it reappears, which is how at-least-once redelivery works there); delay queues (SQS message timers up to 15 minutes); and consumer prefetch in RabbitMQ (cap the unacknowledged messages per consumer so one slow consumer does not hoard work). For long-running multi-step work across services, the pattern on top of all this is a saga: a sequence of local transactions with compensating actions, driven by events.

Backpressure: the queue is a shock absorber, not a landfill

A queue converts an overload into a backlog, which is only progress if the backlog eventually drains. The number to watch is consumer lag (messages behind, or seconds behind) and its derivative: if arrival rate exceeds processing rate at all, lag grows without bound and you have an outage on a delay timer.

  • Do the arithmetic out loud. 5,000 msg/s arriving, 40 ms per message per worker → 25 msg/s per worker → 200 workers to break even, and you want ~1.5x headroom to drain a backlog rather than merely hold it. That is the sizing answer an interviewer wants.
  • Autoscale on lag, not CPU. Consumer CPU is often near-idle while blocked on a downstream call; lag is the signal that actually tracks user harm. In Kafka, remember the partition ceiling — scaling to 300 pods against 50 partitions leaves 250 idle.
  • Push backpressure upstream when the queue is unbounded in practice. Reject or rate-limit at the producer, or shed low-priority work. A 10-hour backlog of stale notifications is worse than dropping them: you'll deliver yesterday's alerts tomorrow.
  • Separate queues by priority and by latency budget. One shared queue means a 4-hour bulk-import backlog delays password-reset emails. Different SLOs deserve different queues — this is the cheapest reliability decision in the whole design.

Ordering: per-key is almost always enough

Global total ordering costs you all parallelism — it means one partition, one consumer, one thread. Almost no product needs it. What products actually need is per-entity ordering: user 42's profile updates must apply in order relative to each other; they have no meaningful relationship to user 91's.

So partition by the entity key. hash(userId) % partitions puts every event for a user in one partition, which has one owner in the consumer group, which processes it in offset order. You get ordering where it matters and full parallelism across keys — this is the same insight as sharding by tenant, applied to a stream.

⚠ Concurrency inside a consumer silently discards the ordering you just paid for You partitioned by userId, then the consumer hands each message to a thread pool or fires off un-awaited async calls. Ordering is gone — the broker's guarantee ends at delivery. If you need intra-consumer parallelism, shard the work by the same key inside the consumer (key-affine worker threads), never round-robin. The related trap: adding partitions later re-maps hash(key) % N, so a key moves partitions and in-flight events for that key can be reordered across the resize. So leave headroom up front, but not unlimited: each partition adds open files, replication and recovery work on the brokers, and consumer-group rebalance time, so pick a number for the parallelism you expect in a year or two (a planning judgement, with some headroom), not the biggest number you can configure.

The outbox pattern: fixing the dual-write problem

Here is a bug that survives every code review because it looks correct:

await db.orders.insert(order);        // 1. commits
await kafka.send("order.created", e); // 2. broker unreachable → throws

The order exists and nobody downstream will ever hear about it. Swapping the order just moves the failure: publish first and crash before the commit, and you've announced an order that doesn't exist. There is no ordering of two independent systems that is safe, because you cannot commit to a database and a broker atomically — that is the dual-write problem, and 2PC is not a real answer at this scale (it blocks on coordinator failure; XA transactions exist in some classic brokers such as IBM MQ and ActiveMQ, but few teams run them at scale).

The fix is to make it a single write. Insert the event into an outbox table in the same transaction as the business row. Now either both exist or neither does. A separate relay reads that table and publishes to the broker. There are two kinds. A polling relay queries the table and marks rows sent (or remembers a high-water mark). A log-based relay such as Debezium tails the write-ahead log and sees each insert; its outbox router treats the table as insert-only (updates are not allowed), so there is no sent flag and you simply delete or expire old rows.

order service one txn orders outbox single ACID transaction relay / CDC (Debezium) Kafka mark sent (polling only) Both rows commit or neither does — the impossible state "order exists, event lost" can no longer occur.
The relay may crash mid-publish and re-send, so the outbox is at-least-once by design — which is fine, because you already made the consumer idempotent.

Cost and caveats, stated plainly: the outbox adds a write per event and a few hundred milliseconds to a few seconds of publish latency (poll interval; CDC is closer to tens of ms). You must prune old rows or the table becomes your biggest one. A polling relay that tracks the highest auto-increment id it has seen can also skip a row: id 7 may commit after id 8 because its transaction was long-running, so poll with a safety lag or use a log-based relay. And the relay gives you at-least-once, never exactly-once — the event ID in the outbox row is the consumer's de-duplication key, which is why these two patterns are always taught together. The mirror-image pattern on the consumer side is the inbox: record the processed event ID transactionally with the effect.

Dry run: one event surviving a consumer crash and a redelivery, exactly once in effect

TimeEventOutbox row (event E77)processed_events tableFulfillment record
t0Order service commits: orders row + outbox row, one transactionstatus = pendingemptynone yet
t1Relay publishes E77 to Kafka (a polling relay would then mark the row sent, but in this trace it crashes first; see t7)status = pending (published, not yet marked)emptynone yet
t2Consumer reads E77 at offset 42, begins processingpendingemptyin progress
t3Consumer crashes before committing the offset or writing processed_eventspendingemptylost — never committed
t4Consumer restarts, resumes from the last committed offset (42, the next record to read) — E77 is redeliveredpendingemptyreprocessing starts
t5Consumer inserts into processed_events(event_id) and writes the fulfillment row, same transactionpending{E77} — first successful insertcreated, exactly once
t6Offset 43 committed (Kafka commits the position of the next record to read, so after finishing offset 42 you commit 43)pending{E77}created
t7 (later)The relay had crashed after publishing E77 but before marking it sent, so on restart it publishes E77 again as a new record (say offset 57), and the consumer reads itsent (finally marked)insert hits the unique constraint — conflict, no-opunchanged — effect already applied

The crash at t3 is exactly the case at-least-once delivery is built to survive, and it's also exactly the case a naive consumer gets wrong: the guarantee isn't that E77 arrives once, it's that redelivery at t4 and the duplicate publish at t7 both land on the same processed_events row and produce the same state either way — a duplicate delivery, not a duplicate effect. (A consumer-group rebalance after t6 would not redeliver E77, because the committed offset 43 is already past it; the duplicate here comes from the relay, which is why the consumer still needs its own de-duplication.)

Recognizing it in an unseen problem

  • Signals: "send a notification", "generate a report", "process the video", "update the search index", "handle Black Friday traffic", or any single request that fans out to three or more downstream systems. If work can finish after the response, it should.
  • The naive design does everything inline and adds servers when it's slow — which scales the fast path and the slow path together, and still fails whenever the slowest third party does.
  • Queue or log? Ask "does anyone need to read these events twice, or will someone need them a year from now?" Yes → log (replay, multiple independent consumer groups, backfilling a new service). No, it's just work to be done → queue (simpler, richer routing, no partition-count planning).
  • Distinguishing it from a request/response cache: both remove latency, but a cache makes reads cheap while a queue makes writes deferrable. If the expensive thing is a read, you want the caching or CDN chapter, not this one.
  • The pitfall to name unprompted: the dual-write. If your design says "save to the DB, then publish an event", say "…via an outbox table, so I'm not doing a dual write" in the same breath. It is one clause and it reliably reads as senior.
  • The follow-up you will get: "what if the consumer processes the same message twice?" The answer is never "it won't." It's an idempotency key plus a processed-events table, written in the same transaction as the effect.
Before you move on
  • Explain the dual-write problem and why the outbox pattern fixes it by making two writes into one.
  • Trace the crash-and-redelivery dry run above and say exactly which row makes the third delivery at t7 a no-op.
  • Explain why "exactly-once delivery" is impossible but "exactly-once effect" is achievable, in your own words.
  • Say what replication factor, acks and min.insync.replicas setting you would use for a durable Kafka topic, and what it costs in availability.
  • Decide, for a concrete workload, whether you want a queue or a log — and justify it with the "does anyone need to replay this" test.
  • Explain why partitioning by entity key gives you ordering where it matters without sacrificing parallelism across keys.
Sources and further reading
  • Apache Kafka documentation: design — replication guarantees, consumer position as the next offset, log compaction, page cache and sequential I/O
  • Apache Kafka 4.2.0 release announcement — share groups (queues for Kafka) declared production-ready
  • KIP-932: Queues for Kafka — per-record acknowledgement and more consumers than partitions
  • RabbitMQ Streams — a replicated log with non-destructive reads and offset replay
  • Amazon SQS message quotas — FIFO limits, high throughput mode, visibility timeout and message timers
  • Amazon SQS high throughput for FIFO queues — limits are per queue partition and scale with message groups
  • Debezium outbox event router — the insert-only outbox table read from the log

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

I3

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. I2Previous · I2CDNs
  2. I3you are here
  3. Next · I4Consistency modelsEvery consistency model is just a rule about which stale reads you are willing to let a user see.17 min read

01On this page

9 sections · 0%

  1. 01Decoupling: what you actually buy
  2. 02Queue vs log: RabbitMQ and Kafka are not the same shape
  3. 03Delivery semantics, done properly
  4. 04Retries, dead letters and poison messages
  5. 05Durability and the other knobs worth naming
  6. 06Backpressure: the queue is a shock absorber, not a landfill
  7. 07Ordering: per-key is almost always enough
  8. 08The outbox pattern: fixing the dual-write problem
  9. 09Recognizing it in an unseen problem

[] chapters · t top