Design a distributed message queue: a full worked answer
Distributed message queue system design, solved: partitioned logs, replication, consumer groups and offsets, delivery guarantees, retention and failure drills.
By devskills editorial team · Last updated
The question
Design a distributed message queue: the broker itself, not a service that uses one. Producers publish messages to named topics, several independent consumer groups read them, messages survive machine failures, and the system carries hundreds of thousands of messages a second.
“Design a distributed message queue” is a hard-level problem in the building the storage itself family. You are not using a database. You are being asked to design one.
The vocabulary flips from "which store do I pick" to replication, quorums, partition assignment and durability. This is the group where every Tier 2 concept is the answer rather than the context.
Before anything: where is the slack?
A queue exists to create slack for everyone else, so its own publish path has very little. The one place it can trade time is durability: confirm a message before or after the replicas have it.
Producers are usually inside someone's request path: an order service publishes "order placed" before it answers the user. If publishing takes 200 ms, every caller of every producer is 200 ms slower. So the broker must accept and confirm writes quickly, which rules out anything heavy, like a per-message database transaction or a consensus round per message.
Consumers are the opposite. A consumer group that is two seconds behind is healthy; one that is ten minutes behind after a deploy is fine as long as it catches up. The whole point of the queue is that consumers can be slower than producers for a while, so consumption has lots of slack, and so do background jobs like deleting old data and moving partitions between machines.
Durability is where the slack gets spent or saved. Confirming a write when one machine has it is fast and loses data when that machine dies. Confirming when three machines have it costs a replication round trip on every publish, forever. That is a per-topic choice, because a click stream and a payment event do not deserve the same promise.
So: Writes are sequential appends to a log, confirmed after a configurable number of replicas have them. Consumption is pull-based and tracked by an offset per consumer group, so slow consumers cost nothing but disk. Everything else runs in the background at low priority.
Requirements
about 5 minAssumptions: an internal platform used by many teams. Producers publish small messages (around 1 KB) to named topics. Several consumer groups read each topic independently, for example a search indexer, an analytics loader and a notifier all reading "orders". Messages are kept for seven days so a consumer can replay after a bug. Peak around a million messages a second across all topics.
The question that changes the storage design: is this a log or a work queue? In a work queue, each message goes to one consumer and is deleted once acknowledged, so the broker tracks every message's state. In a log, messages stay for the retention period and each consumer group just remembers a position (an offset). The log makes several independent consumers and replay nearly free, and it is what lets throughput reach the numbers above. I will design a log, and offer work-queue behaviour on top through consumer groups.
Second question: what ordering do producers need? A total order across a whole topic means one writer and one reader at a time, which caps throughput at one machine. What applications almost always need is order per key: all events for order 123 in sequence. I will promise ordering within a partition, and route messages by key, so per-key order holds.
Third: delivery guarantee. I will default to at-least-once (a message is never lost once confirmed, but a consumer may see it twice after a failure) and say plainly that consumers must be idempotent. Exactly-once processing inside the log is possible with more machinery; exactly-once effects on outside systems are the consumer's job.
Scope I propose: publish, storage and replication, consumer groups and offsets, retention, and failure handling. Out of scope: cross-region replication, schema management, delayed or priority messages, and a management UI.
Stage checklist
- Asked log versus work queue and named what each does to storage
- Scoped ordering to per key (per partition) rather than per topic, and said why
- Chose a delivery guarantee and made consumer idempotency an explicit requirement
- Stated retention and replay as requirements, with a number
- Proposed a scope and left something out on purpose
Actors and lifecycle
about 6 minActors: producers, the broker that leads each partition, follower brokers holding replicas, consumer group members, and a small controller that decides which broker leads which partition. The controller is the actor that acts on its own: when a broker dies, it picks new leaders without anyone asking.
The chain: produced by a client → appended to the partition leader's log → copied by the in-sync followers → committed (every in-sync replica has it) → confirmed to the producer → fetched by a consumer → processed → offset committed by the consumer group → deleted when its segment ages past retention.
Failure branches. Producer times out after the leader wrote the message: it retries, and without protection the message is stored twice. The fix is an idempotent producer: each producer has an id and numbers its messages per partition, and the leader drops a sequence number it has already appended. Leader dies after confirming but before followers copied: if the producer asked for leader-only confirmation, that message is gone, which is why important topics confirm only after the in-sync replicas have it. Consumer crashes after processing but before committing its offset: the next owner of that partition starts from the last committed offset and processes the message again. That is at-least-once, and why consumers must be idempotent. Committing the offset before processing flips it to at-most-once: a crash loses the message instead.
The branch the system owns: a consumer group that falls behind further than the retention period. Retention deletes the oldest segments on schedule, whether or not anyone read them, so that group silently skips data. The system must alert on lag measured against retention, long before it gets there. Another branch: a poison message that crashes the consumer every time. With a log it blocks its whole partition for that group, so after a few attempts it goes to a dead-letter topic and the offset moves on.
Stage checklist
- Defined "committed" as present on every in-sync replica, and tied confirmation to it
- Producer retry duplicates fixed with producer ids and sequence numbers
- Consumer crash before offset commit leads to reprocessing (at-least-once)
- Offset commit before processing leads to loss (at-most-once), and said which you chose
- Lag compared against retention, with an alert
- Poison message moved to a dead-letter topic so the partition is not blocked
The lifecycle, drawn
Numbers
about 6 minPeak 1 million messages a second at about 1 KB is about 1 GB/s coming in. With three replicas, the cluster writes 3 GB/s to disk and moves 2 GB/s between brokers for replication. If three consumer groups read everything, that is another 3 GB/s going out. Network, not CPU, is the first thing to size.
Storage uses the average, not the peak. Say the average is a third of peak, about 330 MB/s. A day is roughly 100,000 seconds, so about 33 TB a day, 230 TB for seven days, and about 700 TB with three replicas. At around 20 TB of usable disk per broker that is 35 or more brokers for storage alone, while throughput alone might need a dozen. So retention decides the cluster size.
Partitions set the parallelism. Within a consumer group, each partition is read by one member at a time, so a group can never use more consumers than there are partitions. If one consumer can process 5,000 messages a second and the busiest topic peaks at 500,000 a second, that topic needs at least 100 partitions, and more for headroom, because adding partitions later moves which partition each key maps to, which breaks per-key order for a while.
Writes are sequential appends and reads are mostly sequential scans of recent data that is still in the operating system's page cache. A single disk that manages a few hundred random operations a second can sustain hundreds of MB/s sequentially. The design depends on keeping both paths sequential.
So the hard part here is replication and retention at disk and network speed, while keeping per-key order. Moving bytes is the workload; the design is about never touching them more than necessary.
Stage checklist
- Multiplied ingress by the replication factor for disk and network
- Counted consumer egress per consumer group
- Sized retention storage from the average rate, including replicas
- Derived a partition count from per-consumer throughput
- Noted that adding partitions later remaps keys and disturbs ordering
- Finished the sentence: replication and retention at disk and network speed, with per-key order
The numbers, drawn
So the hard part is moving and keeping bytes at disk and network speed, sequentially, while preserving per-key order. CPU is rarely the limit.
High-level design
about 12 minTopics split into partitions, each an ordered, append-only log. A partition is the unit of ordering, replication and consumer parallelism, which is exactly what Stage 1 and Stage 3 asked for. On disk, a partition is a series of segment files (say 1 GB each) plus a small index from offset to file position. Appending is a write to the end of the active segment; retention deletes whole old segments, which is cheap.
Producers use a client library that batches messages per partition for a few milliseconds, compresses each batch, and picks the partition by hashing the key (or spreads keyless messages evenly). Batching is what makes a million small messages a second affordable: the broker handles thousands of batches, not a million requests.
Brokers each hold many partition replicas. For each partition one replica is the leader, which takes all writes and, by default, all reads. Followers fetch from the leader like consumers do and append the same bytes. Leaders are spread across brokers so the load is too. Fetches are served largely from the page cache, and the bytes can go from file to socket without being copied through the application.
A controller backed by a small consensus group (three or five nodes running Raft) stores the metadata: topics, partitions, which broker leads each one, and which replicas are in sync. It is the only place that decides a new leader. Message traffic never goes through it; it handles only metadata, which is what consensus is good at.
Consumer groups. A coordinator assigns each partition of a topic to exactly one member of the group, and reassigns when members join, leave or stop sending heartbeats. Each group commits its offsets per partition into an internal, compacted topic, so positions are themselves replicated and durable without a separate database.
Retention and compaction. Time- or size-based retention deletes old segments. For topics that represent the latest state per key (a user's current address), log compaction keeps only the newest message per key instead, which is also how the offsets topic stays small.
Stage checklist
- Partition as the unit of order, replication and parallelism, stored as segment files plus an index
- Producer-side batching, compression and key-based partitioning
- One leader per partition; followers fetch; leaders spread across brokers
- Metadata and leader election in a small consensus group, off the data path
- Consumer group coordinator assigning partitions, offsets stored in a replicated internal topic
- Retention by deleting whole segments; compaction for latest-value topics
The architecture, drawn
Deep dive and tradeoffs
about 14 minHard part one: replication and leader failover. The leader keeps a list of in-sync replicas (ISR): followers that have caught up within a time limit. A message is committed when every replica in the ISR has it, and the leader tracks this as a high watermark; consumers only see messages below it. A producer that asks for confirmation from all in-sync replicas, on a topic configured to require at least two of them, gets a confirmation that survives the loss of any one broker. When the leader dies, the controller picks a new leader from the ISR, so no committed message is lost, and bumps a leader epoch so the old leader, if it comes back, is fenced and truncates anything it wrote that was never committed. Cost: every publish waits for the slowest in-sync follower, and if the ISR shrinks below the minimum, the partition refuses writes rather than accept them unsafely.
The setting interviewers probe here: what if every in-sync replica is gone? Waiting for one to return keeps every committed message and leaves the partition unavailable until then. Electing an out-of-sync replica restores service at once and silently discards whatever it was missing. That is the CAP trade-off as a single configuration flag, and the right answer differs for a click stream and a payments topic.
Hard part two: delivery semantics. At-most-once: commit the offset, then process; a crash loses the message. At-least-once: process, then commit; a crash reprocesses it. Exactly-once within the log: the idempotent producer removes duplicate writes from retries, and transactions let a consumer write its output messages and its input offset atomically, so a read-process-write pipeline inside the queue neither loses nor repeats. Once a consumer touches the outside world (charges a card, sends an email), the queue cannot make that exactly-once. The consumer has to: store an idempotency key with the side effect, or record the processed offset in the same database transaction as its result. Cost: transactions add latency and coordination, so they are opt-in per producer.
Rebalancing and hot partitions. When a member joins or leaves, partitions move between members. A simple protocol pauses the whole group while it reassigns, which shows up as a lag spike on every deploy; an incremental protocol moves only the partitions that need to move. A key that is far hotter than others (one huge customer) overloads its partition and its consumer, and adding partitions does not help, because one key always maps to one partition. Options are to split that key with a suffix where per-key order is not needed, or give that customer its own topic. Cost: either breaks strict ordering for that key, or adds operational special cases.
What to watch. Consumer lag per group and partition, and lag compared with retention; ISR shrink events and partitions below minimum in-sync replicas; publish latency at p99; disk usage against retention; and controller leader changes. Lag is the number the product teams feel.
Stage checklist
- In-sync replicas and the high watermark define "committed"
- Confirmation from all in-sync replicas plus a minimum ISR of two
- New leader chosen from the ISR; leader epoch fences the old leader
- Named the unclean leader election trade-off as availability versus losing data
- At-most-once, at-least-once and exactly-once within the log, each by mechanism
- Exactly-once side effects outside the queue are the consumer's job (idempotency key or offset in the same transaction)
- Rebalance pauses and hot keys, with a mitigation and its cost
And then they push back
Half your servers vanish instantly. What is the user impact?
"The load balancer routes around them." That handles routing; it does not handle the fact that the remaining half now takes double the load and may not have the headroom.
"Two separate problems. Routing: health checks remove them within a few seconds, so there is a short window of failed requests for anyone routed to a dead instance — retries with backoff cover that. Capacity: the survivors now take 2x traffic, so if I was running above 50% utilisation, they saturate and I have turned a partial failure into a total one. That is why capacity planning has to assume losing a chunk of the fleet, not just the peak load. Third, the subtle one: if servers held any local state — in-memory caches, sticky sessions, open connections — that is gone, so the survivors face a cold cache and a reconnection surge at the same time as double traffic. That combination is what takes systems down. I would rather run more, smaller instances so losing half is less likely to be correlated, and keep utilisation low enough to absorb it."
The write worked but the response was lost and the client retried. What happens?
"The client would get an error and could try again." They already tried again. That is the premise. If the retry does the work a second time, you have charged the card twice.
"With idempotency keys, nothing bad: the retry carries the same key, the server finds it already claimed, and returns the original stored response, so the client sees success and the card is charged once. Without them, two charges and a customer complaint. The implementation detail that makes it airtight is inserting the key with a unique constraint in the same transaction as the work, because two separate steps leave a crash window between them. Two things people get wrong: the client must reuse the key across retries rather than generating a fresh one each attempt, which defeats the scheme without raising any error; and if the retry arrives while the first request is still running, I return an in-progress status so the client backs off, rather than doing the work again or returning a wrong answer. Keys expire after a day or so, or the table grows forever."
Why build this rather than use a managed service?
"We would have more control and it would be cheaper at scale." Almost never true early, and control is usually a euphemism for work you have not costed.
"For most of this I would not build. A managed queue, a managed database and a CDN are all things where the operational burden of running them myself vastly exceeds the licence cost, and running them badly is worse than paying. The cases where building wins: when the managed option cannot meet a specific requirement I can name — a latency budget, a data residency rule, a scale beyond what they offer; when the cost crosses over, which happens at large scale for bandwidth and storage more than for compute; and when it is my actual product differentiator. For this system, the matching logic is the product and I would build that; the queue, the object storage and the CDN are commodity and I would buy. The cost of buying: vendor lock-in and a dependency whose outages are my outages, which I would mitigate by keeping the interface narrow enough to replace."
- A worker dies halfway through processing a batch. What is the state of the world?
- One key gets 90% of the traffic. What breaks, and what do you do?
- Two events for the same record arrive out of order. What happens?
- How do you know when you are about to run out of capacity?
- Your storage bill doubles every six months. What do you do?