← Computer Networks
Distributed & Cloud Networking

Message Queues (Kafka, RabbitMQ, SQS)

Watch: "IBM MQ → RabbitMQ → Kafka → Pulsar: How Do Message Queue Architectures Evolve?" — ByteByteGo

Synchronous request/response coupling is the first thing that breaks a system under load — a slow downstream service blocks every caller in front of it. A message queue decouples producers from consumers in time and space: a producer publishes a message and moves on, a consumer processes it whenever it's ready, and neither side needs the other to be up at the same instant. This is why "should this be sync or async, and which queue" is one of the most common system-design interview forks at senior level.

What Is a Message Queue?

A message queue is a middleman that holds messages until whoever is supposed to handle them is ready. Instead of one part of a system directly calling another and waiting for a reply, it drops a note in the queue and moves on immediately; a separate worker picks up that note whenever it has capacity, does the work, and confirms it's done. This means the sender and the handler never have to be available at the exact same moment.

Where This Is Used

A Day-to-Day Example

Think of a hospital triage desk. Patients don't walk straight in and demand the doctor drop everything the instant they arrive — they check in, and their case sits in a queue until a doctor is free to take the next one. The front desk (the producer) doesn't have to personally treat anyone; it just logs the case and moves on to the next patient, while doctors (the consumers) work through the queue at their own pace.

Deep Dive: Why Async — A Concrete Example

Say you're building an invoice-upload feature: a user uploads a PDF, the system runs OCR on it, feeds the extracted text to an LLM to structure the data, then writes the result to a database. Done synchronously, the API handler blocks on OCR (maybe 2s) plus an LLM call (maybe 3-5s) — the client's HTTP connection sits open for 5-7 seconds, and if the LLM call times out or the pod restarts mid-request, the whole upload is lost with no record it ever happened.

Synchronous chain versus queue plus workersynchronous: the client waits 5–7 s for everythingclientAPIOCRLLMDB→ 200 OKasynchronous: 202 Accepted at once, work happens laterclientAPImessage queueworkerOCRLLMDB202 instantlystatus is polled, or pushed via webhook / WebSocket
Synchronously, the request is only as fast and reliable as the slowest step. With a queue, the API just records the job and answers immediately; workers do the slow steps at their own pace, retry on failure, and can be scaled separately.
queueworker path
Show text version
Synchronous (bad):
  Client --POST /invoices--> API --(blocks 5-7s)--> OCR --> LLM --> DB --> 200 OK

Asynchronous (queue-based):
  Client --POST /invoices--> API --202 Accepted (instant)--
                                |
                                v
                          [ Message Queue ]
                                |
                                v
                             Worker --> OCR --> LLM --> DB
                                |
                                v
                     (status polled or pushed via webhook/WebSocket)

The API does the minimum work to accept the job — validate the upload, write a message onto a queue, return 202 Accepted with a job ID — and a separate worker pool pulls from the queue and does the slow part. The client polls GET /invoices/{id}/status or gets notified when it's done. This is the pattern behind almost every "upload and process" feature at scale, and it's also why "how would you avoid a 30-second HTTP request" is a common interview prompt.

Deep Dive: Kafka's Partitioned Commit Log

Kafka is not a traditional queue — it's a distributed, append-only log. A topic is a named stream (e.g. orders), split into partitions for parallelism. Each partition is an ordered, immutable sequence of messages, and each message's position in that sequence is its offset. Producers append to the end of a partition; consumers track an offset and read forward from it — they don't remove messages, so multiple independent consumer groups can each read the same topic at their own pace, and a single consumer can rewind and replay history.

Kafka topic partitions assigned to consumerspartition 0m0m1m2m3→ next offset 4worker-1partition 1m0m1m2→ next offset 3worker-2partition 2m0m1m2m3m4→ next offset 5worker-3worker-4 (idle)consumer groupproducer → partition by hash(key) (same key = same partition = in order)
A topic is split into append-only partitions. Within a group, each partition is read by exactly one consumer, which is what keeps per-key ordering. So parallelism is capped by the partition count: a 4th worker for 3 partitions just sits idle.
messagesconsumer group
Show text version
Topic: orders  (3 partitions, replication factor 3)

Partition 0: [m0][m1][m2][m3]              latest offset → 4
Partition 1: [m0][m1][m2]                  latest offset → 3
Partition 2: [m0][m1][m2][m3][m4]          latest offset → 5

Producer → partitioner (hash of message key, or round-robin) → one partition

Consumer Group "order-workers" (3 running instances):
  Partition 0 → worker-1
  Partition 1 → worker-2
  Partition 2 → worker-3
  (a 4th worker instance joins → sits idle; only 3 partitions to split
   across 4 consumers)

The consumer group is what gives you horizontal scaling: Kafka guarantees each partition is consumed by exactly one member of a group at a time, so adding consumers (up to the partition count) adds parallelism, and Kafka rebalances partition ownership automatically when instances join or leave. This is also Kafka's hard scaling ceiling — a topic with 3 partitions can never be consumed by more than 3 parallel instances in one group, which is why partition count is a capacity-planning decision made up front (repartitioning later is disruptive).

Deep Dive: Delivery Semantics, Ordering, and Failure Handling

Once you introduce a queue, you inherit a new set of correctness problems that a direct function call never has:

Key Pointers

How It Works

  1. A producer publishes a message — to a topic/partition (Kafka), an exchange (RabbitMQ), or directly to a queue (SQS).
  2. The broker (or log, in Kafka's case) stores the message durably, independent of whether any consumer is currently online.
  3. Consumers read messages — by pulling from an offset (Kafka), being pushed or pulling from a queue (RabbitMQ), or polling (SQS).
  4. The consumer processes the message and acknowledges it — advancing the offset (Kafka), acking for deletion (RabbitMQ), or deleting after the visibility timeout window (SQS).
  5. On failure, the message is redelivered (RabbitMQ/SQS) or simply re-read from the last committed offset (Kafka) — which is what makes at-least-once delivery the default everywhere, so consumers must be idempotent. After enough failed attempts, it's routed to a dead-letter queue instead of retried forever.

Try It Yourself

With a local Kafka broker running, you can see partitions, consumer groups, and offsets directly:

# Create a topic with 3 partitions
kafka-topics.sh --create --topic orders --partitions 3 \
  --replication-factor 1 --bootstrap-server localhost:9092

# Produce a few messages (type lines, Ctrl+D to end)
kafka-console-producer.sh --topic orders --bootstrap-server localhost:9092

# Consume as part of a named consumer group
kafka-console-consumer.sh --topic orders --group order-workers \
  --from-beginning --bootstrap-server localhost:9092

# Inspect consumer group lag per partition — the interview-relevant
# command: shows current offset, log-end offset, and LAG (how far
# behind the consumer is)
kafka-consumer-groups.sh --describe --group order-workers \
  --bootstrap-server localhost:9092

For RabbitMQ, rabbitmqctl list_queues name messages consumers shows queue depth and consumer count; the management UI at localhost:15672 visualizes exchange-to-queue bindings directly. For SQS, aws sqs get-queue-attributes --queue-url <url> --attribute-names ApproximateNumberOfMessages gives you queue depth without a console.

Interview Q&A

Q: Why not just use a bigger thread pool instead of a queue?

A: A thread pool still couples the caller to the callee's processing time — the HTTP connection stays open and the caller's own resources (a connection, a thread) are tied up until the work finishes. A queue lets the producer return immediately regardless of how long processing takes, decouples the two services' deploy/scaling cycles, and gives you durability — if the consumer is down, the work waits in the queue instead of failing outright.

Q: How do you handle a message that keeps failing?

A: Bound the retries (e.g. 3-5 attempts with exponential backoff), and after the limit is hit, route it to a dead-letter queue instead of retrying forever or dropping it. That isolates one poison message from blocking the rest of the partition/queue, and gives you a place to inspect and manually replay it once the root cause is fixed.

Q: Kafka or RabbitMQ or SQS — how do you choose?

A: If you need multiple independent consumers to each read the full event stream, need replay, or expect very high throughput (event sourcing, analytics pipelines, audit logs), Kafka fits. If you need flexible routing logic (priority queues, complex topic-based routing, RPC-style reply queues), RabbitMQ's exchange model fits. If you just need a durable point-to-point queue between two services with minimal operational overhead, SQS is the lowest-effort default — you trade flexibility for zero infrastructure to run.

Q: How do you guarantee ordering for a specific entity, like all events for one user?

A: Use the entity ID as the partition/routing key. Kafka hashes the key to consistently route all messages with that key to the same partition, and a single partition is strictly ordered — so all of one user's events land in order on one partition, even though the topic as a whole is unordered across partitions.

Q: What happens if a consumer crashes mid-message in Kafka?

A: If the offset was committed before processing, the message is lost (never retried) — that's "at-most-once" and usually wrong. If the offset is committed only after successful processing, the message will be re-delivered to another consumer in the group on rebalance — that's "at-least-once," the standard default, and it's why the processing logic must be idempotent.

System Design Tie-in

"Design a notification system" (push/email/SMS fan-out to millions of users) is a canonical prompt that hinges on message queues. The write path (an event like "order shipped") goes onto a queue immediately so the triggering service isn't blocked on delivery. A fan-out layer (or Kafka consumer groups per channel) picks it up and routes to channel-specific workers — email workers, push workers, SMS workers — each with their own retry/backoff/DLQ policy, since a third-party SMS provider failing shouldn't block email delivery. Rate limiting per user and per provider, deduplication (the idempotency problem again — don't send the same push twice), and prioritization (a security alert should skip ahead of a marketing blast) are the details that separate a strong answer from a generic one.

Trade-offs

Kafka wins on throughput and replayability but adds operational weight (ZooKeeper/KRaft, partition rebalancing, retention tuning) — reach for it when you need an event log multiple independent consumers read from, or need to reprocess history. RabbitMQ's routing model (topic exchanges, dead-letter queues, priority queues) suits complex task-distribution workflows where you want fine-grained control over who gets what. SQS is the right default when you just need a durable queue between two services and don't want to run anything — it's the lowest-effort option but the least flexible. A common interview gotcha: conflating "message queue" with "pub/sub" — Kafka and SNS/fanout patterns are pub/sub (many consumers see every message); a classic queue (RabbitMQ queue, SQS) is point-to-point (one consumer takes each message).