must-know senior · part of Skills & Topics · Senior SWE Roadmap · related: Observability · Scalability Fundamentals · Microservices vs Monolith · question form: Design a Distributed Message Queue

Why queues exist

A synchronous call chain makes every downstream dependency’s latency and availability your latency and availability. If service A calls B calls C directly, A is only as fast as the slowest of {A, B, C} combined, and A is only as available as the least available of the three. Add more synchronous dependencies and both numbers get worse.

A queue sits between a producer and a consumer and breaks that chain. Three distinct benefits fall out of this:

  • Decoupling: the producer doesn’t need to know who consumes the message, how many consumers there are, or whether they’re currently healthy. Producer and consumer can be deployed, scaled, and fail independently.
  • Load leveling (traffic shaping): a burst of 10,000 requests/sec can be written to a queue at that rate, then drained by consumers at a steady 1,000/sec — the queue absorbs the spike instead of the spike hitting the database or a rate-limited downstream API directly.
  • Async processing: work that doesn’t need to complete before the user gets a response (sending an email, resizing an image, updating a search index) moves off the request’s critical path entirely.
sequenceDiagram
    participant C as Client
    participant A as API Server
    participant E as Email Service
    participant I as Image Resizer
    participant N as Push Service

    C->>A: POST /signup
    A->>E: send welcome email (sync)
    E-->>A: 200 OK (~800ms)
    A->>I: resize avatar (sync)
    I-->>A: 200 OK (~1200ms)
    A->>N: send push notification (sync)
    N-->>A: 200 OK (~400ms)
    A-->>C: 201 Created (~2400ms total)
    Note over A,N: If Image Resizer is slow or down,<br/>the whole signup request fails or hangs.
sequenceDiagram
    participant C as Client
    participant A as API Server
    participant Q as Queue / Broker
    participant E as Email Service
    participant I as Image Resizer
    participant N as Push Service

    C->>A: POST /signup
    A->>A: persist user row
    A->>Q: publish UserSignedUp event
    A-->>C: 201 Created (~50ms)
    Q--)E: UserSignedUp
    Q--)I: UserSignedUp
    Q--)N: UserSignedUp
    Note over E,N: each consumer processes independently,<br/>retries on its own, at its own pace

The request latency drops from ~2400ms to ~50ms, and a slow or dead Image Resizer no longer breaks signup — the message just waits in the queue until the service recovers. This is the single most common “how would you make this scale / not block the user” answer in an interview.

Pub/sub vs point-to-point (work queues)

These are the two fundamentally different delivery models, and mixing them up is a common mistake under interview pressure.

Point-to-point (work queue / competing consumers): each message is delivered to exactly one consumer, chosen from a pool. This is for distributing work — you want the job done once, by whichever worker is free.

flowchart LR
    P[Producer] --> Q[["Queue<br/>(work distribution)"]]
    Q -->|message 1| C1[Consumer 1]
    Q -->|message 2| C2[Consumer 2]
    Q -->|message 3| C1
    Q -->|message 4| C3[Consumer 3]

Each message is consumed once, total. Adding more consumers increases throughput (more workers pulling from the same queue) without any message being processed twice.

Pub/sub (broadcast / fan-out): each message is delivered to every subscriber of the topic. This is for notifying multiple independent systems that something happened — each one reacts in its own way.

flowchart LR
    P[Producer] --> T{{"Topic: order.events"}}
    T --> S1["Subscriber: Email Service"]
    T --> S2["Subscriber: Analytics Pipeline"]
    T --> S3["Subscriber: Fraud Detection"]

Every subscriber gets its own full copy of every message. Adding a new subscriber doesn’t reduce what existing subscribers see — it just adds another independent consumer of the same stream. Kafka-style systems actually give you both at once: a topic is partitioned (point-to-point within a consumer group — one message per group), but multiple independent consumer groups can each subscribe to the same topic and each get every message (pub/sub across groups).

Delivery guarantees

This is the part of the topic interviewers push hardest on, because the “obvious” answer (exactly-once) doesn’t actually exist in the form people assume.

  • At-most-once: send it, don’t wait for confirmation, move on. If the message is lost in transit or the consumer crashes before processing it, it’s just gone. No duplicates, but silent data loss. Rarely acceptable for anything that matters (fine for best-effort metrics/logs).
  • At-least-once: the producer retries until it gets an acknowledgment, and the consumer doesn’t remove/commit a message until it’s fully processed. This guarantees the message is eventually delivered, but the retry can cause the same message to be delivered and processed more than once — e.g., the consumer processes it, crashes before sending the ack, and the broker redelivers it to another consumer.
  • Exactly-once: delivered and processed exactly one time, no more, no less.

Why true exactly-once is effectively impossible across a network boundary: this comes down to the fact that acknowledgment itself is a message that can be lost, and the sender can’t distinguish “the message was never received” from “the message was received and processed, but the ack was lost” — this is the classic Two Generals Problem. If the producer doesn’t retry on a missing ack, it risks under-delivery (violates at-least-once). If it does retry, it risks over-delivery (violates at-most-once) because the original message may have actually succeeded. There is no timeout value or protocol trick that resolves this ambiguity with certainty — you can only pick which failure mode you’re willing to risk.

What real systems do instead is approximate exactly-once semantics on top of at-least-once delivery, using two ingredients:

  1. Idempotent consumers: design the processing operation so that applying it N times has the same effect as applying it once — e.g., SET balance = 500 instead of balance += 50, or an upsert keyed by a natural ID instead of an insert.
  2. Deduplication via a unique message/idempotency key: the consumer (or a shared store) tracks IDs it has already processed and skips duplicates. Commonly implemented as INSERT ... ON CONFLICT DO NOTHING into a processed_message_ids table inside the same transaction as the business-logic write, so the dedup check and the effect are atomic together.

Kafka’s transactional/idempotent producer API achieves exactly-once within Kafka itself (producer → broker, and read-process-write across Kafka topics, via producer IDs + sequence numbers + transactions) — but that guarantee stops at Kafka’s boundary. The moment a consumer’s processing has a side effect outside Kafka (calling a payment API, sending an email), that side effect needs its own idempotency, because Kafka can’t make an external system’s mutation transactional with the offset commit.

GuaranteeMechanismRiskWhen acceptable
At-most-onceFire and forget, no retrySilent message lossBest-effort telemetry, metrics samples
At-least-onceRetry until acked, ack after full processingDuplicate deliveryDefault choice — pair with idempotent consumers
”Exactly-once”At-least-once + idempotency key / dedup storeNone, if dedup is correctly scopedPayments, order creation — anything where duplicates are unacceptable

Partitioning and ordering

A single, un-partitioned queue trivially preserves order — messages come out in the order they went in. Real systems partition (shard) a topic across multiple independent logs for throughput, and that’s where ordering gets subtle.

In Kafka’s model, each message has a partition key. Messages with the same key always hash to the same partition, and within a partition, order is strictly preserved (append-only log, single writer, sequential offsets). But there is no ordering guarantee across partitions — messages for different keys can be interleaved in any order relative to each other, even if they were produced in a specific sequence.

flowchart TD
    Producer -->|"key=userA → hash → partition 0"| P0["Partition 0 (strictly ordered)<br/>m1(userA) → m4(userA) → m7(userA)"]
    Producer -->|"key=userB → hash → partition 1"| P1["Partition 1 (strictly ordered)<br/>m2(userB) → m5(userB)"]
    Producer -->|"key=userC → hash → partition 2"| P2["Partition 2 (strictly ordered)<br/>m3(userC) → m6(userC)"]
    P0 --> CA["Consumer A reads P0<br/>guaranteed order: m1, m4, m7"]
    P1 --> CB["Consumer B reads P1<br/>guaranteed order: m2, m5"]
    P2 --> CC["Consumer C reads P2<br/>guaranteed order: m3, m6"]

m1 was produced before m2 and m3 globally, but nothing guarantees a consumer sees m1 before m2 or m3 — they’re on different partitions consumed independently. If you need strict global ordering, the only options are a single partition (kills parallelism) or accepting per-key ordering as “ordering enough” (true for most real use cases — e.g., you only care that one user’s events, or one order’s state transitions, arrive in order, not that unrelated users’ events interleave in a specific way). This is a near-guaranteed interview follow-up once you mention partitioning: “does this guarantee ordering?” — the correct answer is “per-partition/per-key, not globally,” not a flat yes or no.

Consumer groups and horizontal scaling

A consumer group is a set of consumer instances that split the work of consuming a topic — each partition is assigned to exactly one consumer within the group at a time, so the group as a whole processes every message exactly once (modulo redelivery on failure), but any individual message is handled by only one of the group’s members.

flowchart LR
    subgraph Topic["Topic: orders — 4 partitions"]
        P0[Partition 0]
        P1[Partition 1]
        P2[Partition 2]
        P3[Partition 3]
    end
    subgraph Group["Consumer Group: order-processors"]
        CG1[Consumer 1]
        CG2[Consumer 2]
    end
    P0 --> CG1
    P1 --> CG1
    P2 --> CG2
    P3 --> CG2

This gives a hard ceiling on parallelism: max useful consumers in a group = number of partitions. Scale from 2 to 4 consumers here and each owns exactly one partition; add a 5th and it sits idle with nothing assigned. This is why partition count is a capacity-planning decision made up front — repartitioning an existing topic later is disruptive (it changes which partition a given key hashes to, breaking per-key ordering guarantees for keys that move).

Adding or removing a consumer (scale event, crash, deploy) triggers a rebalance: the group coordinator reassigns partitions among the surviving members. Rebalances briefly pause consumption for the group (older “stop-the-world” rebalancing) or, in modern cooperative-rebalancing protocols, only reassign the specific partitions that changed hands — worth knowing that this is a real operational cost, not free elasticity.

Multiple independent consumer groups can each subscribe to the same topic and each will see every message — that’s the pub/sub-across-groups behavior mentioned above.

Backpressure and consumer lag

Backpressure is what happens when producers write faster than consumers can drain. Left unhandled, the queue grows unbounded, memory pressure builds on the broker, and end-to-end latency (time a message waits before being processed) climbs without limit.

flowchart LR
    Producer --> Q[["Bounded Queue"]]
    Q -->|"within capacity"| Consumer
    Consumer -->|"processing fails, retry 1..N"| Consumer
    Consumer -->|"still failing after N retries"| DLQ[["Dead Letter Queue"]]
    Q -.->|"queue full → reject / 503"| BP["Backpressure signal to producer"]
    Consumer -.->|"consumer lag growing"| AS["Autoscaler adds more consumers"]

The main mitigations:

  • Bounded queues: cap queue size/depth so a slow consumer applies backpressure upstream (producer gets rejected or blocked) instead of the broker running out of memory. Unbounded queues turn a temporary slowdown into an outage.
  • Dead letter queue (DLQ): after N failed processing attempts, move the message aside instead of retrying forever. Critical for ordered, partitioned consumption — one “poison pill” message that always fails would otherwise block every message behind it on that partition indefinitely.
  • Autoscaling consumers: scale consumer count based on a lag metric (Kafka consumer group lag, SQS ApproximateNumberOfMessagesVisible) rather than CPU — CPU can look idle while a consumer is I/O-bound waiting on a slow downstream call, even as lag balloons.
  • Rate limiting upstream: sometimes the right fix is slowing the producer down (see Rate Limiting) rather than scaling consumers indefinitely, especially if the bottleneck is a downstream system the consumers themselves call.

Event-driven architecture patterns

Beyond “use a queue somewhere,” event-driven architecture is a broader style where services react to events rather than being directly invoked. A few patterns worth naming precisely:

  • Event notification: a small event (“OrderCreated, id=123”) with just enough to identify what happened; interested consumers call back to fetch full details if needed. Keeps events small, but the callback reintroduces a synchronous dependency on the source service’s availability.
  • Event-carried state transfer: the event carries the full state the consumer needs (“OrderCreated, id=123, items=[…], total=49.99, …”). No callback needed — consumers stay fully decoupled and can keep operating even if the source service is down — at the cost of larger messages and duplicated data that can drift if an event is missed.
  • Event sourcing: the system of record is the ordered sequence of events, not a mutable row — current state is derived by replaying events (or from a periodically-updated snapshot + tail of events since). Gives a complete audit trail and the ability to rebuild any past state “for free,” at the cost of needing materialized read views (often paired with CQRS) since querying “current state” directly from a raw event log is awkward.
  • Choreography vs orchestration: choreography has each service react to events independently with no central coordinator — good decoupling, but the overall business process (e.g., “place an order”) ends up implicit, scattered across every service’s event handlers, and hard to observe end-to-end. Orchestration uses a central coordinator (a saga orchestrator) that explicitly sequences steps and compensating actions — easier to reason about and monitor, at the cost of reintroducing a coordination point (relevant whenever the design touches Microservices vs Monolith and multi-service transactions).
  • Transactional outbox: solves the “dual write” problem — a service that needs to both write to its own DB and publish an event about that write can’t do both atomically as two separate operations (a crash between them leaves them inconsistent). Fix: write the event into an outbox table in the same DB transaction as the business write; a separate relay process (polling or DB change-data-capture) reads the outbox and publishes to the broker asynchronously, retrying until it succeeds. This guarantees “the event was published” and “the write happened” never disagree.

Comparison: Kafka-style log vs traditional queue vs pub/sub broker

DimensionKafka-style distributed logSQS / RabbitMQ-style queuePub/sub broker (SNS, Google Pub/Sub)
RetentionRetained for a configured period regardless of consumption — replayableMessage deleted once acked/consumedNot replayable beyond the ack deadline (implementation-dependent)
Consumer modelConsumer group tracks its own offset into a partitionPulled and removed; competing consumersFan-out — every subscriber gets its own full copy
OrderingStrict within a partitionFIFO variants exist (SQS FIFO, single RabbitMQ queue) with a throughput trade-offGenerally no ordering guarantee
ReplayYes — rewind consumer offset and reprocessNo — once consumed, it’s goneNo
Delivery guaranteeAt-least-once (exactly-once within Kafka via transactions)At-least-once (at-most-once configurable)At-least-once
Scaling modelAdd partitions (capacity) + consumers (parallelism)Add competing consumersAdd subscribers, each independent
Best forEvent sourcing, stream processing, audit/replay needs, high sustained throughputTask distribution, simple decoupling, “exactly one worker handles this job”Broadcasting one event to many independent, unrelated downstream systems
ExamplesKafka, Apache Pulsar, AWS KinesisAmazon SQS, RabbitMQ, ActiveMQAmazon SNS, Google Cloud Pub/Sub, AWS EventBridge

Interview shorthand: reach for a log when you need replay/audit or high-throughput stream processing; reach for a work queue when you need “exactly one worker does this job, then it’s done”; reach for pub/sub when one event needs to fan out to several independent systems that don’t know about each other.

Interview angles

  • “How would you make this operation asynchronous?” → identify the slow, non-critical-path work, introduce a queue between the write and that work, and state what you gain (lower p99 on the user-facing request) and what you now owe (the work happens eventually, not immediately — is that acceptable for this use case?).
  • “What happens if a consumer crashes mid-processing?” → depends on ack timing: if the message was already acked, at-most-once semantics mean it’s lost; if unacked, the broker redelivers it to another consumer — which is exactly why the consumer’s operation needs to be idempotent.
  • “Does this guarantee ordering?” → per-partition/per-key only, not globally — walk through why, and how partition key choice determines what ordering guarantee you actually get (e.g., partition by order_id if you need one order’s events in order).
  • “How do you get exactly-once?” → explain why you can’t, precisely (ack-loss ambiguity across a network boundary), then pivot to at-least-once + idempotent consumer + dedup key as the real-world answer.
  • “Your consumers can’t keep up, what do you do?” → distinguish a transient spike (bounded queue absorbs it, or autoscale consumers off lag) from a structural mismatch (producer needs rate limiting, or the per-message work needs to get cheaper/parallelized).
  • Tie back to Observability: once work moves off the synchronous path, you lose the simple “request came in, response went out, done” tracing story — a strong answer proactively mentions propagating a trace/correlation ID through the message so an async pipeline stays debuggable.

My Notes