Search Tech Journey

Find topics, journeys and posts

6-month learning plan75 / 130
back to blog
systemsintermediate 55m read

S075 · Message Queues — SQS, RabbitMQ, Kafka as Queue

Decouple producers from consumers so the two never have to be up at the same time. The difference between a queue and a log, and why picking the wrong one is a two-year rewrite.

⚙️SystemsM08 · Distributed Systems· Session 075 of 130 90 min

🎯 Pick the right queue for a job (SQS, RabbitMQ, Kafka), design for at-least-once delivery, and avoid the classic poison-message and reprocessing-flood failures.

Why this session exists

The first serious distributed pattern every backend engineer learns is "put it on a queue". It's the trick behind every asynchronous email, every background image resize, every "we'll process your refund shortly" flow. Get it right and your system becomes gracefully resilient — components can die and recover without dropping work. Get it wrong (wrong delivery semantics, no dead-letter queue, no idempotency) and you'll spend Fridays chasing "why did we send this email 47 times?" mysteries. This session gives you the mental model and the operational discipline.

You will be able to
  • Explain the difference between a queue (point-to-point) and a log (publish-subscribe) in one minute.
  • Compare SQS, RabbitMQ, and Kafka on delivery semantics, ordering, and throughput.
  • Design a consumer that is idempotent + safe against duplicates + robust to poison messages.
  • Configure a dead-letter queue and retry policy without creating a retry storm.
  • Predict which queue system will bottleneck at 10 K/sec, 100 K/sec, and 1 M/sec.

Prerequisites

  • S062 · REST & HTTP — you know what synchronous requests look like; this is the async cousin.
  • S068 · Replication — queue durability = replication under the hood.
  • S072 · Consistency models — "at-least-once" vs "exactly-once" is a consistency-model conversation.


(a) Intuition · 5 min

A ticket window vs a movie theatre
🌍 Real world

Ticket window (queue): customers form a line. Each customer is served by one teller. Once served, they leave — the next customer takes their place. A second customer cannot be served by the same ticket as the first. This is a queue: each message is consumed by exactly one consumer.

Movie theatre (log): 200 people watching the same film. If someone shows up late they miss the earlier scenes (or, on a DVR, they can rewind). Every seat sees every frame. This is a log: many independent consumers can read the same messages, each at their own pace.

💻 Code world

SQS and RabbitMQ are ticket windows: each message is delivered to one consumer, then acknowledged, then deleted. Kafka is a movie theatre: the message stays in the log, and many consumer groups read it independently at their own offsets.

You'll be tempted to think one model is "better". They aren't — they solve different problems. Choosing the wrong one is a two-year rewrite.

The five semantic decisions every queue makes

Every queue system is a specific combination of these
  • Delivery semantics — at-most-once (fire-and-forget), at-least-once (retry on no-ack, may duplicate), exactly-once (rare and expensive).
  • Ordering — none, per-key/partition, or global. Global ordering means single-consumer only.
  • Consumption model — competing consumers (queue) vs subscribers with independent offsets (log).
  • Retention — deleted on ack (queue) vs retained for N days regardless of consumption (log).
  • Ack model — auto-ack (fast, lossy), manual per-message, or batch/offset commit.

A history — from single-node queues to global logs

  1. 1988
    IBM MQSeries
    The grandparent. Financial + insurance systems still run on MQ today. Introduced 'guaranteed delivery' to enterprise.
  2. 2003
    JMS spec + ActiveMQ
    Java-standard messaging API. ActiveMQ becomes the open-source default.
  3. 2007
    AMQP + RabbitMQ
    Advanced Message Queuing Protocol — vendor-neutral, exchange/routing/queue model. RabbitMQ becomes the de-facto microservices queue.
  4. 2011
    Kafka open-sourced by LinkedIn
    'What if the queue were an immutable log?' The idea that reshapes streaming.
  5. 2012
    Amazon SQS FIFO added
    Managed queue with the two flavours: Standard (best-effort, high throughput) and FIFO (ordered, deduped, lower throughput).
  6. 2015
    Kafka Streams + Connect
    Kafka becomes a platform, not just a queue. Real 'log-first' architectures start appearing.
  7. 2020
    Pulsar, Redpanda, Confluent Cloud
    Log-based messaging becomes the default choice for new event-driven systems.

(b) Visual walkthrough · 15 min

Queue vs log — the fundamental picture

The producer→consumer flow with all the failure points

The four canonical failure modes

11
Poison message

A malformed or unprocessable message. Consumer errors, doesn't ack, broker redelivers forever. Fix: max-receive-count + DLQ.

22
Duplicate delivery

Consumer processed but crashed before ack. Broker redelivers. Fix: idempotent processing (dedup by msg_id, use INSERT ON CONFLICT).

33
Slow consumer / backlog

Producer outpaces consumer. Queue grows unbounded. Fix: back-pressure, autoscaling consumers, or drop policy.

44
Reprocessing storm

Consumer bug fixed, replay 3 days of messages. Suddenly downstream API gets 1000x traffic. Fix: rate-limited replays, staged rollouts.

Where each system sits

The five layers of a queue system

Producer SDK
Batches, retries, chooses partition, computes idempotency token.
client
Broker protocol
AMQP (RabbitMQ), Kafka wire protocol, HTTP+JSON (SQS). Determines what SDKs exist.
protocol
Broker cluster
Where messages live. Replicated across nodes. Handles ordering, retention, ack.
storage
Consumer SDK
Pull vs push model, ack strategy, offset commit, dead-letter routing.
client
Observability
Depth per topic, consumer lag, DLQ size, oldest-message-age. These metrics are your queue's vital signs.
ops

Side by side — the three defaults

Amazon SQS

Managed. 'It just works' for small-to-medium.

  • Standard: at-least-once, unordered, ~120 K msg/s per queue
  • FIFO: exactly-once (via dedup), ordered per MessageGroupId, 3 K msg/s cap
  • No brokers to manage — 100% AWS operational
  • Best for: background jobs, decoupling microservices, cross-account workflows
  • Bad for: high-throughput streaming, event replay
RabbitMQ

Flexible routing. AMQP exchanges + queues.

  • Rich routing (topic exchanges, headers exchanges, direct, fanout)
  • Pushes messages to consumers (vs pull) — lower latency
  • ~50 K msg/s per node; use quorum queues for HA
  • Best for: complex routing rules, low-latency task queues
  • Bad for: TB-scale retention, event replay after weeks
Kafka (as queue)

The log-based swiss army knife

  • Millions of msg/s per broker with modest hardware
  • Retention days-to-weeks, replay anytime
  • 'Queue mode' via consumer groups + partition assignment
  • Best for: event sourcing, high-throughput streaming, multiple independent consumers
  • Bad for: complex routing, small workloads (operational overhead)

The mental model to hold


Common misconception
✗ What most people think

"A message queue guarantees exactly-once delivery. That's the whole point — I send a message, the consumer processes it once."

✓ What is actually true

Exactly-once delivery over a network is impossible: the sender cannot distinguish a lost message from a lost acknowledgement, so it must either retry (risking duplicates) or not (risking loss). What systems provide is at-least-once delivery combined with exactly-once processing, achieved by making the consumer idempotent or by committing the side effect and the offset atomically. The guarantee is real; it just lives in the consumer, not in the broker.

Why the myth is so sticky

The myth is sticky because "exactly-once" appears prominently in broker documentation and it is not false — it describes an end-to-end property achievable within a single system's boundaries, typically by transactional writes back into that same system. It becomes wrong the moment your side effect leaves that boundary: charging a card, calling an external API, or writing to a different database. The broker cannot make those atomic with its own offset commit, so the duplicate reaches the outside world.

Prove it to yourself

The scenario every consumer must survive, and which no broker setting prevents:

1. consumer receives message M
2. consumer processes M  (charges a card)
3. consumer crashes BEFORE committing the offset
4. consumer restarts, receives M again

The broker behaved correctly at every step.
If step 2 is not idempotent, the customer is charged twice.
No configuration setting fixes this - only the consumer can.
From first principles
Start with the question

Why do log-based brokers like Kafka scale so much better than traditional queues, when both just move messages?

  1. 1
    A traditional queue deletes a message once it is acknowledged, so the broker must track per-message delivery state for every consumer.
    forced by · the queue owns the decision of what has been consumed, which requires mutable per-message bookkeeping
  2. 2
    That state is mutable, random-access, and must be updated on every acknowledgement, which means random I/O and lock contention proportional to message throughput.
    forced by · messages are acknowledged out of order and independently, so updates cannot be batched into sequential writes
  3. 3
    A log-based broker instead appends messages to an immutable ordered log and stores only a single number per consumer group per partition — the offset.
    forced by · if consumers read in order, their entire progress is expressible as one position
  4. 4
    Appending is sequential I/O, which is dramatically faster than random I/O on both disks and page cache, and it allows the broker to send data directly from the page cache to the socket without copying it through user space.
    forced by · sequential access is the pattern both hardware and the kernel are optimised for, and immutable data needs no transformation before transmission
  5. 5
    Because messages are not deleted on read, many independent consumer groups can read the same log at their own pace, each with its own offset, adding no work for existing consumers.
    forced by · reading is a pure operation on immutable data, so consumers do not contend with each other at all
⇒ Therefore

Therefore the log-based design trades away per-message operations — you cannot acknowledge message 5 while leaving message 3 outstanding — in exchange for sequential I/O, near-free consumer fan-out, and replay. The performance difference is architectural, not incidental.

And note what this predicts: ordering is guaranteed only within a partition, because that is the only unit with a single sequential log. It also predicts that one slow message blocks its whole partition, since progress is a single advancing offset — which is exactly why a poison message can stall a partition indefinitely and why dead-letter queues are mandatory rather than optional. And it predicts replay: since data is not deleted on read, resetting an offset reprocesses history, which is the basis of every event-sourcing architecture built on Kafka.

Mental modelA durable buffer that decouples rates

A queue exists to let a producer and a consumer run at different speeds without either knowing about the other. It absorbs bursts, survives consumer downtime, and converts a synchronous coupling into an asynchronous one.

Queue depth is the visible difference between arrival rate and processing rate. A depth that is flat means the rates match; a depth that grows means the consumer is losing, and it will keep losing until something changes.

  • Queue depth trending upward is never self-correcting. If arrival rate exceeds processing rate even slightly, the backlog grows without bound — so alert on the trend, not on a fixed threshold that will be crossed long after the problem started.
  • Every consumer must be idempotent, without exception. At-least-once is what you get, so design every handler to be safe on replay: use an idempotency key, an upsert, or a processed-message table with a unique constraint.
  • Dead-letter queues are mandatory, and so is monitoring them. A message that fails forever blocks its partition or consumes retry capacity indefinitely, and a dead-letter queue nobody watches is a silent data-loss mechanism with extra steps.
  • Ordering has a cost you must accept knowingly: it exists only within a partition, so guaranteeing order for a key means routing that key to one partition, which caps the parallelism for that key at one consumer. Global ordering means one partition and no parallelism at all.
🔔 Fires when you see

Fire this model when you see: a consumer lag graph climbing steadily · duplicate records downstream after a deploy · a partition that stopped advancing · events processed out of order · a synchronous API call that should have been a message.

The tradeoff

A service must react to an event. Direct synchronous call, or publish to a queue?

Synchronous call
+ you gain the caller learns immediately whether it worked, errors propagate naturally, and debugging is a single stack trace across one request. Consistency is straightforward because the caller can act on the outcome before responding.
− you pay availability multiplies — the caller is down whenever the callee is down. Latency accumulates through the chain, and a slow downstream service consumes the caller's threads and connections until it fails too. Retries must be handled by the caller, in the request path.
pick when when the caller genuinely needs the result to proceed, and the call depth is shallow enough that accumulated latency and availability are acceptable
Asynchronous via a queue
+ you gain producer and consumer availability are independent, bursts are absorbed rather than dropped, retries are the broker's job, and adding a second consumer of the same event requires no change to the producer at all.
− you pay the producer learns nothing about the outcome, so failures surface far from their cause and require separate monitoring. End-to-end debugging needs distributed tracing to be usable, and the system becomes eventually consistent whether or not the business logic was designed for that.
pick when fire-and-forget work, fan-out to multiple consumers, anything slow, and anything that must survive the consumer being down
Synchronous response with asynchronous follow-up
+ you gain the caller gets an immediate acknowledgement — a request ID and an accepted status — while the actual work proceeds asynchronously. Responsive to the user and decoupled underneath.
− you pay you must build a way for the caller to learn the eventual outcome: polling, a callback, or a notification. That is real additional surface area, and the intermediate "in progress" state must be modelled in your data and your UI.
pick when user-facing operations that take longer than a request should — uploads, report generation, anything involving a third party
What a senior engineer actually does

Default to synchronous for reads and asynchronous for side effects. Reads need an answer now; side effects usually need to happen reliably rather than immediately, and reliability is exactly what a durable queue provides.

The strongest signal that you should be asynchronous is a chain of synchronous calls three or more deep. At that depth, availability is the product of every link and latency is the sum, so the whole path is less reliable and slower than any component in it — and the failure is diffuse, appearing as timeouts in a service that is working perfectly. If you find yourself adding retries to a synchronous call to a service that is frequently unavailable, you have already decided you want a queue; you are just implementing it badly in the request path.


(c) Hands-on · 25 min

Let's build a tiny in-memory queue that supports at-least-once delivery, visibility timeouts, and a dead-letter queue — the SQS model in ~100 lines. Then wire a consumer that's idempotent.

#!/usr/bin/env python3
"""tiny_queue.py — SQS-style queue in one file.
 
Features:
  - at-least-once delivery
  - per-message visibility timeout (invisible while being processed)
  - dead-letter queue after N failed receives
  - idempotent consumer using a seen-set
 
Run:  python tiny_queue.py
No dependencies. Uses threads to simulate producer + workers.
"""
from __future__ import annotations
 
import random
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
 
 
@dataclass
class Message:
    body: dict
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    receive_count: int = 0
    invisible_until: float = 0.0
 
 
class Queue:
    def __init__(self, name: str, visibility_timeout: float = 0.5,
                 max_receives: int = 3, dlq: "Queue | None" = None):
        self.name = name
        self.msgs: deque[Message] = deque()
        self.visibility_timeout = visibility_timeout
        self.max_receives = max_receives
        self.dlq = dlq
        self.lock = threading.Lock()
 
    def send(self, body: dict) -> str:
        with self.lock:
            m = Message(body=body)
            self.msgs.append(m)
            return m.id
 
    def receive(self) -> Message | None:
        """Return the first visible message; make it invisible."""
        now = time.time()
        with self.lock:
            for m in self.msgs:
                if m.invisible_until <= now:
                    m.invisible_until = now + self.visibility_timeout
                    m.receive_count += 1
                    if m.receive_count > self.max_receives and self.dlq:
                        # Move to DLQ
                        self.msgs.remove(m)
                        self.dlq.send({**m.body, "_orig_id": m.id})
                        return None
                    return m
            return None
 
    def delete(self, msg_id: str) -> bool:
        with self.lock:
            for m in list(self.msgs):
                if m.id == msg_id:
                    self.msgs.remove(m)
                    return True
            return False
 
    def depth(self) -> int:
        with self.lock:
            return len(self.msgs)
 
 
# --- Idempotent consumer ---
 
class IdempotentProcessor:
    """Simulates a downstream side effect (e.g. sending an email or
    inserting into a database). Records the set of message ids it has
    already handled so duplicates are safely ignored."""
    def __init__(self, name: str, fail_rate: float = 0.0):
        self.name = name
        self.seen: set[str] = set()
        self.processed: list[str] = []
        self.fail_rate = fail_rate
        self.lock = threading.Lock()
 
    def handle(self, msg: Message) -> bool:
        # Simulate transient failure BEFORE the dedup check — so we get
        # legitimate redeliveries.
        if random.random() < self.fail_rate:
            return False
        with self.lock:
            key = msg.body.get("idempotency_key", msg.id)
            if key in self.seen:
                # Duplicate — already processed. Ack silently.
                return True
            self.seen.add(key)
            self.processed.append(key)
            return True
 
 
# --- Worker + producer ---
 
def worker(q: Queue, proc: IdempotentProcessor, stop: threading.Event) -> None:
    while not stop.is_set():
        m = q.receive()
        if m is None:
            time.sleep(0.05)
            continue
        # Simulate variable processing time.
        time.sleep(random.uniform(0.05, 0.2))
        if proc.handle(m):
            q.delete(m.id)  # ack
        # else: don't delete, message becomes visible again after timeout
 
 
def producer(q: Queue, n: int) -> None:
    for i in range(n):
        q.send({"order_id": f"ord-{i:04d}",
                "idempotency_key": f"ord-{i:04d}",  # idempotency by order_id
                "amount": random.randint(10, 500)})
        time.sleep(0.02)
 
 
def main() -> None:
    dlq = Queue("orders-dlq", max_receives=999)
    q = Queue("orders", visibility_timeout=0.3, max_receives=3, dlq=dlq)
    proc = IdempotentProcessor("email-sender", fail_rate=0.3)
 
    stop = threading.Event()
    workers = [threading.Thread(target=worker, args=(q, proc, stop),
                                daemon=True) for _ in range(3)]
    for w in workers:
        w.start()
 
    print("producing 50 messages...")
    producer(q, 50)
 
    # Let workers drain
    print("draining...")
    while q.depth() > 0:
        time.sleep(0.1)
    time.sleep(1.0)  # let in-flight retries settle
    stop.set()
 
    print(f"\nqueue depth: {q.depth()}")
    print(f"DLQ depth:   {dlq.depth()}")
    print(f"unique processed by consumer: {len(proc.seen)}")
    print(f"total handle() calls: {len(proc.processed)}")
    print(f"(handle() calls > 50 = duplicates suppressed by idempotency)")
 
 
if __name__ == "__main__":
    main()

What each block does

Anatomy of the script

Message dataclass
id + body + receive_count + invisible_until. The last two implement the visibility-timeout model — the same fields SQS exposes.
model
Queue.receive · invisibility
First visible message is returned and made invisible for N seconds. If the consumer doesn't ack in time, it becomes visible and another consumer picks it up. This is HOW at-least-once works.
core
max_receives + DLQ routing
After 3 failed processing attempts the message is moved to a dead-letter queue. This is what saves you from poison messages — the classic mistake is not setting a max, so a bad msg cycles forever.
safety
IdempotentProcessor.seen set
The consumer's dedup memory. Real systems use Redis or a DB unique constraint. Once a key is seen, further deliveries are silent no-ops.
idempotency
worker loop
Pull, process, ack. If handle() returns False (transient failure), we do NOT delete — message re-appears after visibility_timeout for retry.
consumer
producer with idempotency_key
Every message carries a natural key (order_id here). Duplicates from broker retries are safe because the consumer dedups on this key.
producer
Try itCause a poison-message DLQ storm, then fix it

Modify handle() to explicitly fail on the poison message:

if msg.body.get("order_id") == "poison":
    return False

Send one such message before the good 50. Print the DLQ contents at the end. In real systems you would then either fix the code + replay from DLQ, or write a manual 'compensate' handler. The whole point of a DLQ is: never lose the message, but stop it from blocking healthy processing.

💡 Hint · Add a specific failing message: `q.send({'order_id': 'poison', 'idempotency_key': 'poison'})` and modify handle() to raise for that id. Watch it hit the DLQ after 3 attempts. Then add an alert: if DLQ depth > 10, fire a page. This is exactly what SRE dashboards monitor.

(d) Production reality · 15 min

War story NetflixHalf a trillion events per day
🔥 What broke

Netflix's original event pipeline used SQS. As they scaled past 100 K events/second, SQS's per-queue throughput cap became a bottleneck; they were sharding across hundreds of queues manually.

Latency for replay (needed for debugging + backfills) was unworkable — SQS deletes messages on ack, so 'replay yesterday' meant re-ingesting from S3, which took hours.

🧯 The fix
Migrated to Kafka. Log-based retention gave them 7 days of replay by default. Throughput per topic partition was 10-100× higher than a single SQS queue. Consumer groups let each analytics team read the same event stream independently at their own pace.
🎓 Lesson to steal
SQS is perfect for 'do this job in the background'. Kafka is perfect for 'many teams need to react to the same stream of events, and replay matters'. The choice is workload-shaped, not vibes-shaped.
Post-mortem
War story Common failure mode — the retry stormdocumented on Stripe, Segment, GitHub engineering blogs
🔥 What broke
A downstream service (e.g. Stripe's fraud check) returns 500 for 5 minutes. All consumers of a task queue fail; messages go back to the queue and are retried. When the downstream recovers, it gets slammed with retry traffic from thousands of accumulated messages within seconds. It falls over again. Repeat.
🧯 The fix
(1) Exponential backoff with jitter per message — retries don't cluster. (2) Circuit breaker per consumer — after N consecutive failures, pause consumption for K seconds. (3) Rate-limited replay when catching up backlogs — do NOT replay a 6-hour queue in 6 minutes. (4) Downstream should have autoscaling + graceful degradation.
🎓 Lesson to steal
Retries are a form of load amplification. Without back-pressure, the queue turns every downstream blip into an outage. Every 'thundering herd' post-mortem is this pattern.
War story Uber· 2019Multi-region migration to Kafka
🔥 What broke
Uber's dispatch, pricing, and analytics all consumed events from a single-region Kafka cluster. When us-east AZ had a partial outage, all these consumers stalled — a rider in Mumbai couldn't get a ride because the events couldn't be dispatched.
🧯 The fix
Multi-region Kafka with per-region producers writing to local clusters, and asynchronous cross-region replication for the streams that needed global views. Consumers moved to 'read from nearest region' with graceful failover.
🎓 Lesson to steal
A single-region queue is a single-region SPOF. If your event pipeline is business-critical, plan for regional isolation. Kafka MirrorMaker / MSK Replicator / Confluent Cluster Linking exist for this reason.
Post-mortem

Where this shows up in the rest of the plan

Queues are the backbone of every event-driven system
S077 · Observability 3 pillars
Queue depth + consumer lag are among the most important production metrics.
S080 · Incident response
Retry storms and DLQ overflow are common incident patterns.
S105 · Kafka internals
The next level down — brokers, partitions, ISR, exactly-once semantics.
S107 · Event-driven architecture
Design patterns built on top of the primitives here (event sourcing, CQRS, saga).
S089 · Rate limiting
Preventing retry storms is a rate-limiting problem.
S128 · System design (interviews)
Almost every interview 'design X' answer includes a queue between components.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, no notes:

  1. Queue vs log — one clean sentence, plus one example of each.
  2. At-least-once vs exactly-once — and why idempotent consumers make the debate moot.
  3. Two failure modes that every queue eventually hits — and the fix for each.

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.