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.
🎯 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.
- 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
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.
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
- 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
- 1988IBM MQSeriesThe grandparent. Financial + insurance systems still run on MQ today. Introduced 'guaranteed delivery' to enterprise.
- 2003JMS spec + ActiveMQJava-standard messaging API. ActiveMQ becomes the open-source default.
- 2007AMQP + RabbitMQAdvanced Message Queuing Protocol — vendor-neutral, exchange/routing/queue model. RabbitMQ becomes the de-facto microservices queue.
- 2011Kafka open-sourced by LinkedIn'What if the queue were an immutable log?' The idea that reshapes streaming.
- 2012Amazon SQS FIFO addedManaged queue with the two flavours: Standard (best-effort, high throughput) and FIFO (ordered, deduped, lower throughput).
- 2015Kafka Streams + ConnectKafka becomes a platform, not just a queue. Real 'log-first' architectures start appearing.
- 2020Pulsar, Redpanda, Confluent CloudLog-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
A malformed or unprocessable message. Consumer errors, doesn't ack, broker redelivers forever. Fix: max-receive-count + DLQ.
Consumer processed but crashed before ack. Broker redelivers. Fix: idempotent processing (dedup by msg_id, use INSERT ON CONFLICT).
Producer outpaces consumer. Queue grows unbounded. Fix: back-pressure, autoscaling consumers, or drop policy.
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
Side by side — the three defaults
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
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
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
"A message queue guarantees exactly-once delivery. That's the whole point — I send a message, the consumer processes it once."
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.
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.
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.Why do log-based brokers like Kafka scale so much better than traditional queues, when both just move messages?
- 1A 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
- 2That 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
- 3A 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
- 4Appending 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
- 5Because 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 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.
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.
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.
A service must react to an event. Direct synchronous call, or publish to a queue?
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
Modify handle() to explicitly fail on the poison message:
if msg.body.get("order_id") == "poison":
return FalseSend 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.
(d) Production reality · 15 min
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.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- Queue vs log — one clean sentence, plus one example of each.
- At-least-once vs exactly-once — and why idempotent consumers make the debate moot.
- 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.