Dev Skills

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.

hard

By devskills editorial team · Last updated

Common trap
Try it before reading. How the stages work: the five stages.

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.

What kind of problem this is

“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?

What slack means

Publish: a few milliseconds to tens of milliseconds. Consumption: seconds behind is normal. Rebalancing, retention and repair: hours.

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.

1

Requirements

about 5 min
What a strong answer sounds like

Assumptions: 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.

The costs named here
A log with consumer offsets instead of per-message acknowledgement → costs: No per-message delete or redelivery of one message by itself; a slow or stuck message blocks its partition for that consumer group. In exchange, many groups and replay cost almost nothing.
Say this
"I will build a partitioned log. Order is promised per partition, and producers choose the partition by key, so all events for one order stay in sequence. Delivery is at-least-once, so consumers must be idempotent. Each consumer group tracks its own offset, which makes replay and extra consumers nearly free."
Common trap
Promising global ordering and exactly-once delivery up front. Both sound good and both make the system far slower or impossible; say what you promise instead.
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
2

Actors and lifecycle

about 6 min
What you are being asked for: List the actors, including the system, then write a message's life as a chain of states, and add the failure branches.
What a strong answer sounds like

Actors: 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.

Common trap
Ending the chain at "consumer receives it". The interesting failures are between processing and committing the offset.
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

ProducedproducerAppended to leader logleader brokerCommitted (all in-sync replicas)followersFetched by a consumerconsumerProcessed + offset committedconsumer groupDeleted by retentionsystemProducer timed out and retriedproducer id + sequence number; the leader drops the duplicateLeader died before followers copiedconfirm only after the in-sync replicas have it; otherwise this message is lostLeader died after commitcontroller elects a new leader from the in-sync set; epoch fences the old leaderConsumer crashed before committing its offsetnext owner reprocesses from the last offset; consumer must be idempotentPoison message crashes the consumer every timeafter N attempts, move it to a dead-letter topic and advance the offsetGroup fell behind past retentionlag-versus-retention alert fires long before data is deleted unread
A message is safe once it is committed (every in-sync replica has it), and processed once its consumer group has committed an offset past it. The failures live between those points.
3

Numbers

about 6 min
What you are being asked for: Estimate throughput, storage, partitions and machines. Then finish: "So the hard part here is ___."
What a strong answer sounds like

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

The costs named here
Over-provisioning partitions up front → costs: More files, more leaders for the controller to track, and slower failover per broker. Accepted, because adding partitions later disturbs key-to-partition mapping.
Say this
"A gigabyte a second in becomes three on disk and two across the network for replication, plus whatever the consumer groups read. Seven days of retention is around 700 TB with replicas, so storage, not throughput, sets the broker count. The busiest topic needs at least 100 partitions, or the consumers cannot keep up."
Common trap
Sizing only ingress. Replication and fan-out to consumer groups multiply it several times over.
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

Peak ingress (1M msg/s × 1 KB)~1 GB/s
Written to disk across the cluster (3 replicas)~3 GB/s
Seven days of retention, with replicas~700 TB: this sets the broker count

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.

Ingress is the smallest number on the page. Replication, consumer fan-out and retention multiply it.
4

High-level design

about 12 min
What a strong answer sounds like

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

The costs named here
Pull-based consumers instead of the broker pushing → costs: Consumers poll and add a little latency when idle. In exchange, each consumer controls its own pace, batches its fetches, and a slow consumer never makes the broker buffer for it.
Reads served only by partition leaders → costs: Leaders carry all the read load for their partitions. In exchange, a consumer can never read a message that is not yet committed or that a failover might discard.
Say this
"Each partition is an append-only log in 1 GB segment files, led by one broker and followed by two others. A small Raft group stores which broker leads what, and message traffic never touches it. Consumer groups get partitions assigned by a coordinator and commit offsets to an internal compacted topic."
Common trap
Putting every message through the consensus group. Consensus is for the metadata; a Raft round per message caps the whole cluster at one leader's speed.
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

producefetchfetchwho leadspullcommit offsetProducersbatch, compress, hash keyPartition leaderappend-only segmentsFollowerin-sync replicaFollowerin-sync replicaControllerRaft: leaders, ISR, topicsConsumer groupone member per partitionOffsets topiccompacted, replicated
Partitions are logs replicated across brokers. A small consensus group holds the metadata and never sees message traffic. Consumer groups pull and track their own offsets.
Queueat least onceServicekey seen?Database
first delivery — the key is newapplied once, and the key is recordedthe same message again — it stops here, nothing reaches the database
At-least-once in action: the consumer processed the message but crashed before committing its offset, so the next owner of the partition processes it again. The consumer has to make that harmless.
5

Deep dive and tradeoffs

about 14 min
What a strong answer sounds like

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

The costs named here
Requiring at least two in-sync replicas for important topics → costs: If two of three replicas are down, the partition stops accepting writes. Correct for payments; probably wrong for a metrics stream.
Offering transactions for exactly-once pipelines → costs: Extra latency and a coordinator on the write path, so they are opt-in rather than the default.
Say this
"A message is committed when every in-sync replica has it, and producers on important topics wait for exactly that, with a minimum of two in sync. On failover the controller picks a new leader from the in-sync set and fences the old one with an epoch. Delivery is at-least-once; consumers that touch the outside world store an idempotency key with the side effect, because the queue cannot make an email exactly-once."
Common trap
Claiming the queue gives exactly-once delivery end to end. It can within the log; the consumer's side effects are still at-least-once unless the consumer makes them idempotent.
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?
A weak answer

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

A strong answer

"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?
A weak answer

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

A strong answer

"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?
A weak answer

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

A strong answer

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

The topics this answer used