Message queues and streams
How work queues and logs like SQS, RabbitMQ and Kafka work. Acks, redelivery, dead letters, ordering, idempotent consumers and the outbox pattern.
- Read first
- Choosing a databasePartitioning
The problem it solves
Section titled “The problem it solves”A shop takes 2,000 orders a second at the evening peak. After the order commits, checkout calls five other systems before it answers: the email provider, inventory, fraud scoring, loyalty points and analytics. Each one is a problem.
- Latency adds up. The email provider alone has a p99 of 1.5 seconds. The shopper waits for the slowest of five calls, and for an email they will read later.
- Availability multiplies. Five dependencies at 99.9% each give checkout 0.999⁵ = 99.5%. That’s about 3.6 hours of failed checkouts a month, caused by systems that have nothing to do with taking money.
- Bursts pass straight through. A flash sale brings 20,000 orders a second for 10 minutes. The email provider accepts 5,000 a second, so 15,000 calls a second fail, and with them the orders.
- Retries have nowhere to live. Say the email provider is down for 20 minutes. The request handler can retry for a few seconds at most. Then the email is gone.
A message queue puts a durable buffer between the code that knows something happened (the producer) and the code that acts on it (the consumer). Checkout commits the order, hands the broker a message, and answers in 30 ms. Consumers take messages at their own pace. That buys three things:
| What it buys | What happens | In the example |
|---|---|---|
| Decoupling | The producer does not know or wait for who consumes | Checkout no longer fails when the email provider fails |
| Buffering | Bursts become a backlog that drains at the consumers’ rate | 9 million emails queue up during the sale and drain in about 50 minutes |
| Retries | A failed message stays in the broker and is tried again later | The 20 minute outage delays emails; it loses none |
The price: the work happens later and somewhere else, failures surface minutes later in another service, and every message can arrive twice. Anything the response depends on, such as “was the card authorized”, stays synchronous.
- Color key
- Service
- Database
- Cache
- Queue or stream
- Background worker
How it works
Section titled “How it works”Step 1: A durable buffer
Section titled “Step 1: A durable buffer”The simplest queue is a list in the producer’s memory, such as a Go channel. Everything in it dies with the process, and every deploy restarts every process. So the buffer must live outside the producer and survive a crash.
A table in the database works, and is often the right first answer. Insert a row, and workers claim
rows with SELECT ... FOR UPDATE SKIP LOCKED. One Postgres instance handles a few thousand jobs a
second this way. Past that, or when many teams share it, a dedicated broker takes over. It stores
messages on disk, and replicates them across machines. It answers the producer only once the message
is safe.
That answer is the first guarantee in the system. Amazon SQS replies after storing the message
redundantly across availability zones. Kafka with acks=all replies after every
in-sync replica has it. A producer that gets no reply must send again. So even the producer side
creates duplicates. Kafka’s idempotent producer (on by default since 3.0) drops those retries, by a
per-partition sequence number. SQS FIFO drops a repeated deduplication id within 5 minutes. Neither
helps when the application itself sends twice.
Step 2: Acknowledge after the work
Section titled “Step 2: Acknowledge after the work”If the broker deleted a message as soon as it handed it out, a consumer that crashed one line later would lose it. So a work queue (SQS, RabbitMQ, ActiveMQ, Azure Service Bus) hands a message out and keeps it:
- Receive. The broker gives the message to one consumer. It hides it from the others for the visibility timeout (SQS: 30 seconds by default, up to 12 hours).
- Ack. After doing the work, the consumer acks, and only then is the message deleted
(
DeleteMessagein SQS,basic.ackin RabbitMQ). - Redeliver. With no ack, because the consumer crashed or hung, the message becomes visible again for another consumer. SQS waits for the timeout. RabbitMQ redelivers when the consumer’s channel closes.
- Color key
- Background worker
- Queue or stream
Nothing was lost, and order 105 was charged twice. Step 6 deals with that.
Two settings decide how well this works:
- The visibility timeout must be longer than the slowest normal processing time, several times
the p99. Too short, and a slow but healthy consumer has its message handed to a second consumer
while it is still working. For long jobs, the consumer extends the timeout as it goes (a
heartbeat:
ChangeMessageVisibilityin SQS). - Prefetch is how many unacked messages one consumer may hold (
basic.qosin RabbitMQ, up to 10 per receive in SQS). It is the consumer’s backpressure: a slow consumer stops getting more until it acks what it has.
Step 3: Retries, poison messages and dead letters
Section titled “Step 3: Retries, poison messages and dead letters”Redelivery is also the retry mechanism. A consumer that gets a 503 from the email provider does not ack, and the message comes back after the timeout.
Some messages fail every time: a malformed payload, an order id that no longer exists, or a bug that throws on one input. This is a poison message. Retried forever, it eats consumer time. In an ordered queue, it blocks everything behind it.
So the broker counts receives. After a limit (SQS maxReceiveCount, RabbitMQ quorum queue
delivery-limit), it moves the message to a dead-letter queue (DLQ). That’s a separate queue
nobody consumes automatically. A person can inspect the message there, fix the cause, and
redrive it back to the main queue.
Good consumers also classify errors themselves. A validation error goes to the DLQ at once, since a retry cannot fix it. A timeout is retried with exponential backoff:
import java.util.ArrayList;import java.util.List;import java.util.Optional;
class Message { final String id; final String body; int receives = 0; long visibleAt = 0; // while a consumer holds it, hidden until this time (seconds) long receipt = 0; // changes on every receive, so only the latest holder can ack
Message(String id, String body) { this.id = id; this.body = body; }}
record Delivery(Message message, long receipt) {}
// The core of an SQS or RabbitMQ queue. A received message is hidden, not removed: only an ack// removes it. If the consumer dies, the message reappears when its visibility timeout runs out,// so a crash delays work instead of losing it.class WorkQueue { private final long visibilityS; private final int maxReceives; final List<Message> deadLetters = new ArrayList<>(); private final List<Message> messages = new ArrayList<>(); private long receipts = 0;
WorkQueue(long visibilityS, int maxReceives) { this.visibilityS = visibilityS; this.maxReceives = maxReceives; }
synchronized void send(String id, String body) { messages.add(new Message(id, body)); }
synchronized boolean isEmpty() { return messages.isEmpty(); }
synchronized Optional<Delivery> receive(long now) { for (Message m : List.copyOf(messages)) { if (m.visibleAt > now) continue; // in flight with another consumer if (m.receives >= maxReceives) { // It failed every attempt: a poison message. Park it so it stops eating // consumer time, and keep it for a person to inspect and redrive. messages.remove(m); deadLetters.add(m); continue; } m.receives++; m.visibleAt = now + visibilityS; m.receipt = ++receipts; return Optional.of(new Delivery(m, m.receipt)); } return Optional.empty(); }
// A heartbeat for slow work: keep the message hidden while still working on it. Without // this, a job longer than the timeout is handed to a second consumer. synchronized boolean extend(String id, long receipt, long now) { for (Message m : messages) { if (m.id.equals(id) && m.receipt == receipt) { m.visibleAt = now + visibilityS; return true; } } return false; }
// The receipt proves this consumer still holds the message. One whose timeout ran out, and // whose message went to someone else, gets false and must not assume its work was the only // run. synchronized boolean ack(String id, long receipt) { return messages.removeIf(m -> m.id.equals(id) && m.receipt == receipt); }}
public class Main { public static void main(String[] args) { WorkQueue q = new WorkQueue(30, 3); q.send("m1", "charge order 101"); q.send("m2", "corrupt payload"); q.send("m3", "charge order 103");
long now = 0; boolean crashed = false; while (!q.isEmpty()) { Optional<Delivery> got = q.receive(now); if (got.isEmpty()) { now++; // everything left is in flight: wait for a timeout to run out continue; } Message m = got.get().message(); long receipt = got.get().receipt(); if (m.id.equals("m1") && !crashed) { crashed = true; // the consumer dies holding m1, before its ack System.out.printf("t=%ds %s received, consumer crashes%n", now, m.id); continue; } if (m.body.equals("corrupt payload")) { System.out.printf("t=%ds %s fails, attempt %d%n", now, m.id, m.receives); continue; // no ack: it comes back after the timeout } System.out.printf("t=%ds %s done, acked %b%n", now, m.id, q.ack(m.id, receipt)); }
// m3 finished before m1: redelivery after a crash reorders a plain queue. System.out.println("dead letters: " + q.deadLetters.stream().map(m -> m.id).toList()); // t=0s m1 received, consumer crashes // t=0s m2 fails, attempt 1 // t=0s m3 done, acked true // t=30s m1 done, acked true // t=30s m2 fails, attempt 2 // t=60s m2 fails, attempt 3 // dead letters: [m2] }}from dataclasses import dataclass
@dataclassclass Message: id: str body: str receives: int = 0 visible_at: int = 0 # while a consumer holds it, hidden until this time (seconds) receipt: int = 0 # changes on every receive, so only the latest holder can ack
# The core of an SQS or RabbitMQ queue. A received message is hidden, not removed:# only an ack removes it. If the consumer dies, the message reappears when its# visibility timeout runs out, so a crash delays work instead of losing it.class WorkQueue: def __init__(self, visibility_s: int, max_receives: int) -> None: self.visibility_s = visibility_s self.max_receives = max_receives self.dead_letters: list[Message] = [] self._messages: list[Message] = [] self._receipts = 0
def send(self, id: str, body: str) -> None: self._messages.append(Message(id, body))
def is_empty(self) -> bool: return not self._messages
def receive(self, now: int) -> tuple[Message, int] | None: for m in list(self._messages): if m.visible_at > now: continue # in flight with another consumer if m.receives >= self.max_receives: # It failed every attempt: a poison message. Park it so it stops eating # consumer time, and keep it for a person to inspect and redrive. self._messages.remove(m) self.dead_letters.append(m) continue m.receives += 1 m.visible_at = now + self.visibility_s self._receipts += 1 m.receipt = self._receipts return m, m.receipt return None
def extend(self, id: str, receipt: int, now: int) -> bool: # A heartbeat for slow work: keep the message hidden while still working on it. # Without this, a job longer than the timeout is handed to a second consumer. for m in self._messages: if m.id == id and m.receipt == receipt: m.visible_at = now + self.visibility_s return True return False
def ack(self, id: str, receipt: int) -> bool: # The receipt proves this consumer still holds the message. One whose timeout ran # out, and whose message went to someone else, gets False and must not assume # its work was the only run. for m in self._messages: if m.id == id and m.receipt == receipt: self._messages.remove(m) return True return False
if __name__ == "__main__": q = WorkQueue(visibility_s=30, max_receives=3) q.send("m1", "charge order 101") q.send("m2", "corrupt payload") q.send("m3", "charge order 103")
now, crashed = 0, False while not q.is_empty(): got = q.receive(now) if got is None: now += 1 # everything left is in flight: wait for a timeout to run out continue m, receipt = got if m.id == "m1" and not crashed: crashed = True # the consumer dies holding m1, before its ack print(f"t={now}s {m.id} received, consumer crashes") continue if m.body == "corrupt payload": print(f"t={now}s {m.id} fails, attempt {m.receives}") continue # no ack: it comes back after the timeout print(f"t={now}s {m.id} done, acked {q.ack(m.id, receipt)}")
# m3 finished before m1: redelivery after a crash reorders a plain queue. print("dead letters:", [m.id for m in q.dead_letters]) # t=0s m1 received, consumer crashes # t=0s m2 fails, attempt 1 # t=0s m3 done, acked True # t=30s m1 done, acked True # t=30s m2 fails, attempt 2 # t=60s m2 fails, attempt 3 # dead letters: ['m2']using System;using System.Collections.Generic;using System.Linq;
public sealed class QueuedMessage{ public QueuedMessage(string id, string body) { Id = id; Body = body; }
public string Id { get; } public string Body { get; } public int Receives { get; set; } public long VisibleAt { get; set; } // while a consumer holds it, hidden until this time (s) public long Receipt { get; set; } // changes on every receive: only the latest holder can ack}
// The core of an SQS or RabbitMQ queue. A received message is hidden, not removed: only an ack// removes it. If the consumer dies, the message reappears when its visibility timeout runs out,// so a crash delays work instead of losing it.public sealed class WorkQueue{ private readonly object _gate = new(); private readonly List<QueuedMessage> _messages = new(); private readonly long _visibilityS; private readonly int _maxReceives; private long _receipts;
public WorkQueue(long visibilityS, int maxReceives) { _visibilityS = visibilityS; _maxReceives = maxReceives; }
public List<QueuedMessage> DeadLetters { get; } = new();
public void Send(string id, string body) { lock (_gate) _messages.Add(new QueuedMessage(id, body)); }
public bool IsEmpty() { lock (_gate) return _messages.Count == 0; }
public (QueuedMessage Message, long Receipt)? Receive(long now) { lock (_gate) { foreach (var m in _messages.ToList()) { if (m.VisibleAt > now) continue; // in flight with another consumer if (m.Receives >= _maxReceives) { // It failed every attempt: a poison message. Park it so it stops eating // consumer time, and keep it for a person to inspect and redrive. _messages.Remove(m); DeadLetters.Add(m); continue; } m.Receives++; m.VisibleAt = now + _visibilityS; m.Receipt = ++_receipts; return (m, m.Receipt); } return null; } }
// A heartbeat for slow work: keep the message hidden while still working on it. Without // this, a job longer than the timeout is handed to a second consumer. public bool Extend(string id, long receipt, long now) { lock (_gate) { var m = _messages.FirstOrDefault(x => x.Id == id && x.Receipt == receipt); if (m is null) return false; m.VisibleAt = now + _visibilityS; return true; } }
// The receipt proves this consumer still holds the message. One whose timeout ran out, and // whose message went to someone else, gets false and must not assume its work was the only // run. public bool Ack(string id, long receipt) { lock (_gate) return _messages.RemoveAll(m => m.Id == id && m.Receipt == receipt) > 0; }}
public static class Program{ public static void Main() { var q = new WorkQueue(30, 3); q.Send("m1", "charge order 101"); q.Send("m2", "corrupt payload"); q.Send("m3", "charge order 103");
long now = 0; var crashed = false; while (!q.IsEmpty()) { var got = q.Receive(now); if (got is null) { now++; // everything left is in flight: wait for a timeout to run out continue; } var (m, receipt) = got.Value; if (m.Id == "m1" && !crashed) { crashed = true; // the consumer dies holding m1, before its ack Console.WriteLine($"t={now}s {m.Id} received, consumer crashes"); continue; } if (m.Body == "corrupt payload") { Console.WriteLine($"t={now}s {m.Id} fails, attempt {m.Receives}"); continue; // no ack: it comes back after the timeout } Console.WriteLine($"t={now}s {m.Id} done, acked {q.Ack(m.Id, receipt)}"); }
// m3 finished before m1: redelivery after a crash reorders a plain queue. Console.WriteLine("dead letters: " + string.Join(", ", q.DeadLetters.Select(m => m.Id))); // t=0s m1 received, consumer crashes // t=0s m2 fails, attempt 1 // t=0s m3 done, acked True // t=30s m1 done, acked True // t=30s m2 fails, attempt 2 // t=60s m2 fails, attempt 3 // dead letters: m2 }}package main
import ( "fmt" "sync")
type Message struct { ID string Body string Receives int visibleAt int64 // while a consumer holds it, hidden until this time (seconds) receipt int64 // changes on every receive, so only the latest holder can ack}
// WorkQueue is the core of an SQS or RabbitMQ queue. A received message is hidden, not// removed: only an ack removes it. If the consumer dies, the message reappears when its// visibility timeout runs out, so a crash delays work instead of losing it.type WorkQueue struct { mu sync.Mutex visibilityS int64 maxReceives int DeadLetters []*Message messages []*Message receipts int64}
func NewWorkQueue(visibilityS int64, maxReceives int) *WorkQueue { return &WorkQueue{visibilityS: visibilityS, maxReceives: maxReceives}}
func (q *WorkQueue) Send(id, body string) { q.mu.Lock() defer q.mu.Unlock() q.messages = append(q.messages, &Message{ID: id, Body: body})}
func (q *WorkQueue) IsEmpty() bool { q.mu.Lock() defer q.mu.Unlock() return len(q.messages) == 0}
// Receive returns a message and its receipt, or nil when nothing is visible.func (q *WorkQueue) Receive(now int64) (*Message, int64) { q.mu.Lock() defer q.mu.Unlock() for i := 0; i < len(q.messages); i++ { m := q.messages[i] if m.visibleAt > now { continue // in flight with another consumer } if m.Receives >= q.maxReceives { // It failed every attempt: a poison message. Park it so it stops eating // consumer time, and keep it for a person to inspect and redrive. q.messages = append(q.messages[:i], q.messages[i+1:]...) q.DeadLetters = append(q.DeadLetters, m) i-- continue } m.Receives++ m.visibleAt = now + q.visibilityS q.receipts++ m.receipt = q.receipts return m, m.receipt } return nil, 0}
// Extend is a heartbeat for slow work: keep the message hidden while still working on it.// Without this, a job longer than the timeout is handed to a second consumer.func (q *WorkQueue) Extend(id string, receipt, now int64) bool { q.mu.Lock() defer q.mu.Unlock() for _, m := range q.messages { if m.ID == id && m.receipt == receipt { m.visibleAt = now + q.visibilityS return true } } return false}
// Ack checks the receipt, which proves this consumer still holds the message. One whose// timeout ran out, and whose message went to someone else, gets false and must not assume// its work was the only run.func (q *WorkQueue) Ack(id string, receipt int64) bool { q.mu.Lock() defer q.mu.Unlock() for i, m := range q.messages { if m.ID == id && m.receipt == receipt { q.messages = append(q.messages[:i], q.messages[i+1:]...) return true } } return false}
func main() { q := NewWorkQueue(30, 3) q.Send("m1", "charge order 101") q.Send("m2", "corrupt payload") q.Send("m3", "charge order 103")
var now int64 crashed := false for !q.IsEmpty() { m, receipt := q.Receive(now) if m == nil { now++ // everything left is in flight: wait for a timeout to run out continue } if m.ID == "m1" && !crashed { crashed = true // the consumer dies holding m1, before its ack fmt.Printf("t=%ds %s received, consumer crashes\n", now, m.ID) continue } if m.Body == "corrupt payload" { fmt.Printf("t=%ds %s fails, attempt %d\n", now, m.ID, m.Receives) continue // no ack: it comes back after the timeout } fmt.Printf("t=%ds %s done, acked %t\n", now, m.ID, q.Ack(m.ID, receipt)) }
// m3 finished before m1: redelivery after a crash reorders a plain queue. for _, m := range q.DeadLetters { fmt.Println("dead letter:", m.ID) } // t=0s m1 received, consumer crashes // t=0s m2 fails, attempt 1 // t=0s m3 done, acked true // t=30s m1 done, acked true // t=30s m2 fails, attempt 2 // t=60s m2 fails, attempt 3 // dead letter: m2}class Message { receives = 0; visibleAt = 0; // while a consumer holds it, hidden until this time (seconds) receipt = 0; // changes on every receive, so only the latest holder can ack
constructor( readonly id: string, readonly body: string, ) {}}
// The core of an SQS or RabbitMQ queue. A received message is hidden, not removed: only an ack// removes it. If the consumer dies, the message reappears when its visibility timeout runs out,// so a crash delays work instead of losing it.class WorkQueue { readonly deadLetters: Message[] = []; private messages: Message[] = []; private receipts = 0;
constructor( private readonly visibilityS: number, private readonly maxReceives: number, ) {}
send(id: string, body: string): void { this.messages.push(new Message(id, body)); }
isEmpty(): boolean { return this.messages.length === 0; }
receive(now: number): { message: Message; receipt: number } | undefined { for (const m of [...this.messages]) { if (m.visibleAt > now) continue; // in flight with another consumer if (m.receives >= this.maxReceives) { // It failed every attempt: a poison message. Park it so it stops eating consumer // time, and keep it for a person to inspect and redrive. this.messages = this.messages.filter((x) => x !== m); this.deadLetters.push(m); continue; } m.receives++; m.visibleAt = now + this.visibilityS; m.receipt = ++this.receipts; return { message: m, receipt: m.receipt }; } return undefined; }
// A heartbeat for slow work: keep the message hidden while still working on it. Without // this, a job longer than the timeout is handed to a second consumer. extend(id: string, receipt: number, now: number): boolean { const m = this.messages.find((x) => x.id === id && x.receipt === receipt); if (!m) return false; m.visibleAt = now + this.visibilityS; return true; }
// The receipt proves this consumer still holds the message. One whose timeout ran out, and // whose message went to someone else, gets false and must not assume its work was the only // run. ack(id: string, receipt: number): boolean { const before = this.messages.length; this.messages = this.messages.filter((m) => !(m.id === id && m.receipt === receipt)); return this.messages.length < before; }}
const q = new WorkQueue(30, 3);q.send('m1', 'charge order 101');q.send('m2', 'corrupt payload');q.send('m3', 'charge order 103');
let now = 0;let crashed = false;while (!q.isEmpty()) { const got = q.receive(now); if (!got) { now++; // everything left is in flight: wait for a timeout to run out continue; } const { message: m, receipt } = got; if (m.id === 'm1' && !crashed) { crashed = true; // the consumer dies holding m1, before its ack console.log(`t=${now}s ${m.id} received, consumer crashes`); continue; } if (m.body === 'corrupt payload') { console.log(`t=${now}s ${m.id} fails, attempt ${m.receives}`); continue; // no ack: it comes back after the timeout } console.log(`t=${now}s ${m.id} done, acked ${q.ack(m.id, receipt)}`);}
// m3 finished before m1: redelivery after a crash reorders a plain queue.console.log('dead letters:', q.deadLetters.map((m) => m.id));// t=0s m1 received, consumer crashes// t=0s m2 fails, attempt 1// t=0s m3 done, acked true// t=30s m1 done, acked true// t=30s m2 fails, attempt 2// t=60s m2 fails, attempt 3// dead letters: [ 'm2' ]The output shows a third property of work queues: m3 finished before m1, because m1 went back into the queue after the crash. Redelivery reorders a plain queue.
Step 4: Keep the messages: a log
Section titled “Step 4: Keep the messages: a log”A work queue deletes a message once it is processed. That fits jobs: send this email, resize this image. It fits events badly. Say inventory, analytics and fraud all want every order event. A queue needs a copy per consumer. And when analytics fixes a bug and wants to reprocess last week, the messages are gone.
A log (Kafka, Amazon Kinesis, Redpanda, Apache Pulsar) keeps every message for a retention period, whether or not anyone read it. Kafka’s default is 7 days. Messages are appended to a partition, an ordered file. Each gets the next offset: 0, 1, 2 and so on. The broker keeps no per-message state. Instead, each consumer remembers one number per partition. That’s the offset it has processed up to, which it commits back to the broker.
- Color key
- Service
- Database
- Cache
- Queue or stream
- Background worker
Three properties follow from storing offsets instead of deleting messages:
- Consumer groups. Each group gets every message. Within a group, each partition is read by exactly one member. So a group scales out to at most as many members as there are partitions. The email group and the analytics group above read the same data, at different positions. And a slow group does not slow the other.
- Replay. Resetting a group’s offset to yesterday reprocesses yesterday. A new consumer can start from the beginning of retention and build its state from history.
- Cheap reads, coarse acks. A consumer acknowledges a position, not a message. It cannot skip one bad message and come back to it later. Everything before the committed offset is done.
Design a message queue covers how the broker stores and replicates partitions.
Step 5: Ordering, and what it costs
Section titled “Step 5: Ordering, and what it costs”A work queue with competing consumers has no useful order. Two consumers take m1 and m2 at the same moment, m2 finishes first, and a retried m1 can come back minutes later. If m1 is “order created” and m2 is “order cancelled”, the cancellation is applied to an order that does not exist yet.
Order is kept by giving up parallelism for related messages:
- Kafka and Kinesis keep order within a partition. The producer sends a key. The partition is
hash(key) mod partitions. So every event for order 101 lands on the same partition, and is read in order by one consumer. - SQS FIFO queues keep order per message group id. They hand out a group’s next message only after the previous one is acked, or expires. Different groups proceed in parallel.
- RabbitMQ keeps order in a queue with a single consumer. Per-key order needs a consistent-hash exchange, or one queue per shard of keys.
Order per key (one order, one account, one conversation) is almost always what the product needs. Order across all keys means one partition and one consumer, and that consumer’s throughput.
Per-key order has costs that show up in production:
| Cost | What happens |
|---|---|
| Head-of-line blocking | A slow or failing message stalls every message behind it in its partition or group, including unrelated keys that hashed there |
| Parallelism cap | A group has at most one active consumer per partition. Extra consumers sit idle |
| Hot keys | One key with 20% of traffic makes its partition the bottleneck, and adding partitions does not split a key |
| Repartitioning | Adding partitions changes hash(key) mod N, so a key’s new events land elsewhere while old ones are still being read |
Step 6: Delivery semantics, stated precisely
Section titled “Step 6: Delivery semantics, stated precisely”Whether the consumer acks before or after the work decides how the crash in step 2 ends. The guarantees are about effects after failures: how many times the work takes effect when consumers, brokers and networks fail at the worst moment.
| Guarantee | How | After a crash at the worst moment | Fits |
|---|---|---|---|
| At most once | Ack (or commit the offset) first, then process | The message is lost | Metrics samples, presence pings, anything lossy |
| At least once | Process first, then ack | The work happens twice | The default for almost everything |
| Effectively once | At least once, plus a consumer that recognizes repeats | Delivered twice, takes effect once | Payments, inventory, emails, any side effect |
Try both orders. The consumer crashes once while handling m5, in the gap between its two steps. Run Charge, then ack on the queue, then switch to Ack, then charge, then turn on the idempotent consumer. Then do the same on the log, and replay it from offset 0.
Queue
- m1
- m2
- m3
- m4
- m5
- m6
- m7
- m8
- m9
- m10
Consumer idle
Charges made
- m10
- m20
- m30
- m40
- m50
- m60
- m70
- m80
- m90
- m100
- Charged once
- 0
- Lost
- 0
- Charged twice or more
- 0
- Duplicates skipped
- 0
- Dead-lettered
- 0
Charge order: none yet
Acking first loses m5, and no amount of deduplication brings it back. Acking after charges m5 twice. On the log, it is worse. The consumer commits once per batch, so a crash replays everything since the last commit, and m4 is charged twice too. Only at-least-once delivery with an idempotent consumer gives one charge per message. And it survives a full replay.
Exactly-once delivery, in the literal sense, means every message handed over once and only once. No system can promise that. The ack itself travels over a network. If it is lost, the broker cannot tell “processed, ack lost” from “never processed”. It must choose between redelivering and dropping. What systems do offer is exactly-once effects, by making a repeated delivery harmless. There are three ways:
- Make the operation idempotent by nature.
SET status = 'shipped'can run twice.balance = balance - 10cannot. Write absolute values, upserts keyed by the event id, or conditional writes (UPDATE ... WHERE version = 7) that fail the second time. - Record what you processed, in the same transaction as the effect. An inbox table of applied message ids sits next to the consumer’s data, and a repeat hits its unique key. A log consumer can store its offset in that transaction instead, and seek to it on restart. But that misses a repeat the producer wrote at a new offset.
- Use the broker’s transactions, inside the broker only. Kafka transactions commit a stream processor’s output records and its input offsets atomically. That gives exactly once, from one topic to another. Anything outside Kafka, an email or a row in Postgres, is not covered. The message queue problem walks through the mechanism.
The inbox on a log: one transaction per record, and an offset commit only after the whole batch. A crash before the commit replays the batch, and each repeat costs a lookup:
import java.util.HashMap;import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;import java.util.StringJoiner;import java.util.TreeMap;
// eventId is set by the producer, so a republished event keeps its id.record LogRecord(long offset, String eventId, int orderId, int points) {}
// One partition of a log. The broker keeps every record and one number per consumer group: the// committed offset, where the group resumes after a crash or a rebalance.class Partition { final List<LogRecord> records; long committed = 0;
Partition(List<LogRecord> records) { this.records = records; }
List<LogRecord> poll(long start, int maxRecords) { int from = (int) Math.min(start, records.size()); return records.subList(from, Math.min(from + maxRecords, records.size())); }}
// The consumer's own database. The inbox table of applied event ids lives next to the balances// it protects, so the dedupe record and the effect commit together.class LoyaltyDb { final Map<Integer, Integer> points = new HashMap<>(); final Set<String> inbox = new HashSet<>(); // PRIMARY KEY (event_id), pruned after a few days
synchronized boolean apply(LogRecord r) { // BEGIN ... COMMIT // Dedupe by event id, not by offset: a relay that republishes after a crash writes the // same event at a new offset, and only the id shows it is a repeat. if (!inbox.add(r.eventId())) return false; // seen before: the effect already happened points.merge(r.orderId(), r.points(), Integer::sum); return true; }}
class CrashException extends RuntimeException { CrashException(String message) { super(message); }}
public class Main { static void consume(Partition log, LoyaltyDb db, Long crashAfter) { long pos = log.committed; // a (re)started consumer resumes from the committed offset System.out.println("poll from offset " + pos); List<LogRecord> batch; while (!(batch = log.poll(pos, 3)).isEmpty()) { for (LogRecord r : batch) { String result = db.apply(r) ? "applied" : "duplicate, skipped"; System.out.println(" offset " + r.offset() + " " + r.eventId() + " " + result); if (crashAfter != null && r.offset() == crashAfter) { throw new CrashException("consumer crashes before committing"); } } pos = batch.get(batch.size() - 1).offset() + 1; log.committed = pos; // commit only after the whole batch took effect: at least once System.out.println("commit offset " + pos); } }
public static void main(String[] args) { Partition log = new Partition(List.of( new LogRecord(0, "evt-a", 101, 10), new LogRecord(1, "evt-b", 102, 5), new LogRecord(2, "evt-c", 103, 20), new LogRecord(3, "evt-b", 102, 5), // the relay crashed and published evt-b again new LogRecord(4, "evt-d", 104, 8), new LogRecord(5, "evt-e", 105, 12))); LoyaltyDb db = new LoyaltyDb(); try { consume(log, db, 4L); } catch (CrashException e) { System.out.println(e.getMessage()); } System.out.println("restart from committed offset " + log.committed); consume(log, db, null); StringJoiner totals = new StringJoiner(" "); new TreeMap<>(db.points).forEach((k, v) -> totals.add(k + "=" + v)); System.out.println("points: " + totals + ", events applied " + db.inbox.size()); // poll from offset 0 // offset 0 evt-a applied // offset 1 evt-b applied // offset 2 evt-c applied // commit offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d applied // consumer crashes before committing // restart from committed offset 3 // poll from offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d duplicate, skipped // offset 5 evt-e applied // commit offset 6 // points: 101=10 102=5 103=20 104=8 105=12, events applied 5 }}import threadingfrom dataclasses import dataclass
@dataclass(frozen=True)class Record: offset: int event_id: str # set by the producer, so a republished event keeps its id order_id: int points: int
# One partition of a log. The broker keeps every record and one number per consumer# group: the committed offset, where the group resumes after a crash or a rebalance.class Partition: def __init__(self, records: list[Record]) -> None: self.records = records self.committed = 0
def poll(self, start: int, max_records: int) -> list[Record]: return self.records[start : start + max_records]
# The consumer's own database. The inbox table of applied event ids lives next to the# balances it protects, so the dedupe record and the effect commit together.class LoyaltyDb: def __init__(self) -> None: self.lock = threading.Lock() self.points: dict[int, int] = {} self.inbox: set[str] = set() # PRIMARY KEY (event_id), pruned after a few days
def apply(self, r: Record) -> bool: # Dedupe by event id, not by offset: a relay that republishes after a crash writes the # same event at a new offset, and only the id shows it is a repeat. with self.lock: # BEGIN if r.event_id in self.inbox: return False # seen before: the effect already happened self.inbox.add(r.event_id) self.points[r.order_id] = self.points.get(r.order_id, 0) + r.points return True # COMMIT
class CrashError(Exception): pass
def consume(log: Partition, db: LoyaltyDb, crash_after: int | None = None) -> None: pos = log.committed # a (re)started consumer resumes from the group's committed offset print(f"poll from offset {pos}") while batch := log.poll(pos, 3): for r in batch: result = "applied" if db.apply(r) else "duplicate, skipped" print(f" offset {r.offset} {r.event_id} {result}") if r.offset == crash_after: raise CrashError("consumer crashes before committing") pos = batch[-1].offset + 1 log.committed = pos # commit only after the whole batch took effect: at least once print(f"commit offset {pos}")
if __name__ == "__main__": log = Partition( [ Record(0, "evt-a", 101, 10), Record(1, "evt-b", 102, 5), Record(2, "evt-c", 103, 20), Record(3, "evt-b", 102, 5), # the outbox relay crashed and published evt-b again Record(4, "evt-d", 104, 8), Record(5, "evt-e", 105, 12), ] ) db = LoyaltyDb() try: consume(log, db, crash_after=4) except CrashError as e: print(e) print(f"restart from committed offset {log.committed}") consume(log, db) totals = " ".join(f"{k}={v}" for k, v in sorted(db.points.items())) print(f"points: {totals}, events applied {len(db.inbox)}") # poll from offset 0 # offset 0 evt-a applied # offset 1 evt-b applied # offset 2 evt-c applied # commit offset 3 # offset 3 evt-b duplicate, skipped # offset 4 evt-d applied # consumer crashes before committing # restart from committed offset 3 # poll from offset 3 # offset 3 evt-b duplicate, skipped # offset 4 evt-d duplicate, skipped # offset 5 evt-e applied # commit offset 6 # points: 101=10 102=5 103=20 104=8 105=12, events applied 5using System;using System.Collections.Generic;using System.Linq;
// EventId is set by the producer, so a republished event keeps its id.public sealed record LogRecord(long Offset, string EventId, int OrderId, int Points);
// One partition of a log. The broker keeps every record and one number per consumer group: the// committed offset, where the group resumes after a crash or a rebalance.public sealed class Partition{ private readonly List<LogRecord> _records;
public Partition(List<LogRecord> records) => _records = records;
public long Committed { get; set; }
public List<LogRecord> Poll(long start, int maxRecords) => _records.Skip((int)start).Take(maxRecords).ToList();}
// The consumer's own database. The inbox table of applied event ids lives next to the balances// it protects, so the dedupe record and the effect commit together.public sealed class LoyaltyDb{ private readonly object _gate = new();
public Dictionary<int, int> Points { get; } = new(); public HashSet<string> Inbox { get; } = new(); // PRIMARY KEY (event_id), pruned after days
public bool Apply(LogRecord r) { lock (_gate) // BEGIN ... COMMIT { // Dedupe by event id, not by offset: a relay that republishes after a crash writes // the same event at a new offset, and only the id shows it is a repeat. if (!Inbox.Add(r.EventId)) return false; // seen before: the effect already happened Points[r.OrderId] = Points.GetValueOrDefault(r.OrderId) + r.Points; return true; } }}
public sealed class CrashException : Exception{ public CrashException(string message) : base(message) { }}
public static class Program{ private static void Consume(Partition log, LoyaltyDb db, long? crashAfter = null) { var pos = log.Committed; // a (re)started consumer resumes from the committed offset Console.WriteLine($"poll from offset {pos}"); for (var batch = log.Poll(pos, 3); batch.Count > 0; batch = log.Poll(pos, 3)) { foreach (var r in batch) { var result = db.Apply(r) ? "applied" : "duplicate, skipped"; Console.WriteLine($" offset {r.Offset} {r.EventId} {result}"); if (r.Offset == crashAfter) throw new CrashException("consumer crashes before committing"); } pos = batch[^1].Offset + 1; log.Committed = pos; // commit only after the whole batch took effect: at least once Console.WriteLine($"commit offset {pos}"); } }
public static void Main() { var log = new Partition(new List<LogRecord> { new(0, "evt-a", 101, 10), new(1, "evt-b", 102, 5), new(2, "evt-c", 103, 20), new(3, "evt-b", 102, 5), // the outbox relay crashed and published evt-b again new(4, "evt-d", 104, 8), new(5, "evt-e", 105, 12), }); var db = new LoyaltyDb(); try { Consume(log, db, 4); } catch (CrashException e) { Console.WriteLine(e.Message); } Console.WriteLine($"restart from committed offset {log.Committed}"); Consume(log, db); var totals = string.Join( " ", db.Points.OrderBy(p => p.Key).Select(p => $"{p.Key}={p.Value}")); Console.WriteLine($"points: {totals}, events applied {db.Inbox.Count}"); // poll from offset 0 // offset 0 evt-a applied // offset 1 evt-b applied // offset 2 evt-c applied // commit offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d applied // consumer crashes before committing // restart from committed offset 3 // poll from offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d duplicate, skipped // offset 5 evt-e applied // commit offset 6 // points: 101=10 102=5 103=20 104=8 105=12, events applied 5 }}package main
import ( "errors" "fmt" "sort" "strings" "sync")
type Record struct { Offset int EventID string // set by the producer, so a republished event keeps its id OrderID int Points int}
// Partition is one partition of a log. The broker keeps every record and one number per// consumer group: the committed offset, where the group resumes after a crash or a rebalance.type Partition struct { Records []Record Committed int}
func (p *Partition) Poll(start, maxRecords int) []Record { start = min(start, len(p.Records)) return p.Records[start:min(start+maxRecords, len(p.Records))]}
// LoyaltyDB is the consumer's own database. The inbox table of applied event ids lives next to// the balances it protects, so the dedupe record and the effect commit together.type LoyaltyDB struct { mu sync.Mutex points map[int]int inbox map[string]bool // PRIMARY KEY (event_id), pruned after a few days}
func NewLoyaltyDB() *LoyaltyDB { return &LoyaltyDB{points: map[int]int{}, inbox: map[string]bool{}}}
func (db *LoyaltyDB) Apply(r Record) bool { db.mu.Lock() // BEGIN defer db.mu.Unlock() // COMMIT // Dedupe by event id, not by offset: a relay that republishes after a crash writes the same // event at a new offset, and only the id shows it is a repeat. if db.inbox[r.EventID] { return false // seen before: the effect already happened } db.inbox[r.EventID] = true db.points[r.OrderID] += r.Points return true}
var errCrash = errors.New("consumer crashes before committing")
// consume stops at crashAfter to stand in for a process that dies there (-1: no crash).func consume(log *Partition, db *LoyaltyDB, crashAfter int) error { pos := log.Committed // a (re)started consumer resumes from the group's committed offset fmt.Printf("poll from offset %d\n", pos) for batch := log.Poll(pos, 3); len(batch) > 0; batch = log.Poll(pos, 3) { for _, r := range batch { result := "duplicate, skipped" if db.Apply(r) { result = "applied" } fmt.Printf(" offset %d %s %s\n", r.Offset, r.EventID, result) if r.Offset == crashAfter { return errCrash } } pos = batch[len(batch)-1].Offset + 1 log.Committed = pos // commit only after the whole batch took effect: at least once fmt.Printf("commit offset %d\n", pos) } return nil}
func main() { log := &Partition{Records: []Record{ {0, "evt-a", 101, 10}, {1, "evt-b", 102, 5}, {2, "evt-c", 103, 20}, {3, "evt-b", 102, 5}, // the outbox relay crashed and published evt-b again {4, "evt-d", 104, 8}, {5, "evt-e", 105, 12}, }} db := NewLoyaltyDB() if err := consume(log, db, 4); err != nil { fmt.Println(err) } fmt.Printf("restart from committed offset %d\n", log.Committed) _ = consume(log, db, -1)
orders := make([]int, 0, len(db.points)) for id := range db.points { orders = append(orders, id) } sort.Ints(orders) totals := make([]string, len(orders)) for i, id := range orders { totals[i] = fmt.Sprintf("%d=%d", id, db.points[id]) } fmt.Printf("points: %s, events applied %d\n", strings.Join(totals, " "), len(db.inbox)) // poll from offset 0 // offset 0 evt-a applied // offset 1 evt-b applied // offset 2 evt-c applied // commit offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d applied // consumer crashes before committing // restart from committed offset 3 // poll from offset 3 // offset 3 evt-b duplicate, skipped // offset 4 evt-d duplicate, skipped // offset 5 evt-e applied // commit offset 6 // points: 101=10 102=5 103=20 104=8 105=12, events applied 5}interface LogRecord { offset: number; eventId: string; // set by the producer, so a republished event keeps its id orderId: number; points: number;}
// One partition of a log. The broker keeps every record and one number per consumer group: the// committed offset, where the group resumes after a crash or a rebalance.class Partition { committed = 0;
constructor(readonly records: LogRecord[]) {}
poll(start: number, maxRecords: number): LogRecord[] { return this.records.slice(start, start + maxRecords); }}
// The consumer's own database. The inbox table of applied event ids lives next to the balances// it protects, so the dedupe record and the effect commit together.class LoyaltyDb { readonly points = new Map<number, number>(); readonly inbox = new Set<string>(); // PRIMARY KEY (event_id), pruned after a few days
// One transaction. Node runs it without interleaving; a real database needs BEGIN ... COMMIT. apply(r: LogRecord): boolean { // Dedupe by event id, not by offset: a relay that republishes after a crash writes the same // event at a new offset, and only the id shows it is a repeat. if (this.inbox.has(r.eventId)) return false; // seen before: the effect already happened this.inbox.add(r.eventId); this.points.set(r.orderId, (this.points.get(r.orderId) ?? 0) + r.points); return true; }}
class CrashError extends Error {}
function consume(log: Partition, db: LoyaltyDb, crashAfter?: number): void { let pos = log.committed; // a (re)started consumer resumes from the group's committed offset console.log(`poll from offset ${pos}`); for (let batch = log.poll(pos, 3); batch.length > 0; batch = log.poll(pos, 3)) { for (const r of batch) { const result = db.apply(r) ? 'applied' : 'duplicate, skipped'; console.log(` offset ${r.offset} ${r.eventId} ${result}`); if (r.offset === crashAfter) throw new CrashError('consumer crashes before committing'); } pos = batch[batch.length - 1].offset + 1; log.committed = pos; // commit only after the whole batch took effect: at least once console.log(`commit offset ${pos}`); }}
const log = new Partition([ { offset: 0, eventId: 'evt-a', orderId: 101, points: 10 }, { offset: 1, eventId: 'evt-b', orderId: 102, points: 5 }, { offset: 2, eventId: 'evt-c', orderId: 103, points: 20 }, // The outbox relay crashed after publishing evt-b, so it published it again. { offset: 3, eventId: 'evt-b', orderId: 102, points: 5 }, { offset: 4, eventId: 'evt-d', orderId: 104, points: 8 }, { offset: 5, eventId: 'evt-e', orderId: 105, points: 12 },]);const db = new LoyaltyDb();try { consume(log, db, 4);} catch (e) { if (!(e instanceof CrashError)) throw e; console.log(e.message);}console.log(`restart from committed offset ${log.committed}`);consume(log, db);const totals = [...db.points] .sort(([a], [b]) => a - b) .map(([k, v]) => `${k}=${v}`) .join(' ');console.log(`points: ${totals}, events applied ${db.inbox.size}`);// poll from offset 0// offset 0 evt-a applied// offset 1 evt-b applied// offset 2 evt-c applied// commit offset 3// offset 3 evt-b duplicate, skipped// offset 4 evt-d applied// consumer crashes before committing// restart from committed offset 3// poll from offset 3// offset 3 evt-b duplicate, skipped// offset 4 evt-d duplicate, skipped// offset 5 evt-e applied// commit offset 6// points: 101=10 102=5 103=20 104=8 105=12, events applied 5When the side effect is in someone else’s system, the target must cooperate. Payment providers accept an idempotency key, and return the original result for a repeat. An email provider without one leaves a small window, between the send and recording it. In that window, a crash still sends twice. The window can be shrunk, never closed.
Go deeperWhat SQS FIFO's exactly-once means1 min
SQS FIFO queues advertise exactly-once processing. Two mechanisms sit behind it. On the producer
side, it drops a message whose deduplication id (or content hash) matches one sent in the last 5
minutes. That removes duplicates from producer retries. On the consumer side, a message group
delivers its next message only after the current one is deleted. Neither covers a consumer that
charges a card, and then dies before DeleteMessage. Once the visibility timeout expires, the
message is delivered again. The consumer still has to be idempotent.
Step 7: Publish reliably from a database write
Section titled “Step 7: Publish reliably from a database write”Checkout must do two things when an order is placed: commit the order to its database and publish
an OrderPlaced event. These are two systems with no transaction across them, a dual write,
and both orders of operation break:
- Commit, then publish. The process dies between the two (a deploy, an OOM kill). Or the broker is down for a minute. The order exists, but the event never does. No email, no stock reservation, and nothing notices.
- Publish, then commit. The commit fails on a constraint. Downstream now acts on an order that does not exist.
The transactional outbox makes the event part of the database write. Checkout inserts the
order and an outbox row describing the event in one local transaction. A separate relay
reads unsent outbox rows, publishes them, and marks them sent once the broker acknowledges.
- Color key
- Queue or stream
- Database
- Service
A crash before the commit leaves neither the order nor the event. A crash after it leaves a row the relay will publish. A crash between publishing and marking publishes it twice, so the outbox gives at-least-once publishing, and the consumers’ idempotency from step 6 absorbs the repeat. The databases page has the outbox and its relay in code.
The relay is a poller, or a change data capture connector, compared under options. Watch out for one trap. A relay that asks for rows above “the highest id I published” skips rows. Id 102 can commit before id 101. The relay has moved past 101 by the time it commits. Mark rows sent instead, or use CDC, which follows commit order. Prune sent rows after a day or so.
Step 8: Fan-out, delays and priorities
Section titled “Step 8: Fan-out, delays and priorities”Fan-out. Many consumers each wanting every message is publish-subscribe: one topic, and one independent subscription per consumer with its own position, retries and dead letters.
| Broker | Fan-out mechanism |
|---|---|
| AWS | An SNS topic with one SQS queue subscribed per consumer |
| RabbitMQ | A fanout or topic exchange, with one queue bound per consumer |
| Kafka, Kinesis | One consumer group per consumer, all reading the same topic |
| Google Pub/Sub | One subscription per consumer on a topic |
A subscriber that is slow or down falls behind on its own subscription and does not hold up the others.
Delays. SQS delays a message up to 15 minutes (DelaySeconds). A consumer can postpone one by
extending its visibility timeout, up to 12 hours. RabbitMQ uses a queue TTL that dead-letters expired
messages into the real queue. Kafka has no delays. The usual answer is retry topics with fixed
delays (orders-retry-1m, orders-retry-10m). Each is read by a consumer that waits until a
message is due. “Send this at 9 a.m. on Friday” belongs in a
job scheduler backed by a database.
- Color key
- Service
- Database
- Cache
- Queue or stream
- Background worker
Moving a failure to a retry topic unblocks its partition. But it also gives up that key’s order. Later events for the same order are processed before the retried one. Use it where order does not matter per key. Or have the consumer park the whole key until the retry succeeds.
Priorities. Give each class of work its own queue and consumer pool, instead of relying on priority flags. Then one-time passwords never wait behind a marketing campaign.
Options and tradeoffs
Section titled “Options and tradeoffs”Work queue or log
Section titled “Work queue or log”| Property | Work queue (SQS, RabbitMQ) | Log (Kafka, Kinesis) |
|---|---|---|
| After processing | Message deleted | Message kept until retention runs out |
| Acknowledgment | Per message | One offset per partition: everything before it |
| A failing message | Retried on its own, others continue, then a DLQ | Blocks its partition unless the consumer moves it aside |
| Ordering | None (standard), per group (FIFO) | Per partition, so per key |
| Scaling consumers | Add consumers any time, no limit | Up to one consumer per partition in a group |
| Several consumers | A queue per consumer, fed by a fan-out topic | A consumer group each, on the same data |
| Replay | No | Yes, by resetting offsets |
| Wins for | Jobs and tasks: emails, thumbnails, webhooks, uneven work | Event streams: many readers, replay, ordering, high throughput |
Which broker
Section titled “Which broker”| Option | Model | Ordering | Retention | Operations | When it wins |
|---|---|---|---|---|---|
| Amazon SQS | Managed work queue, pull | None, or per group in FIFO | 4 days default, 14 max | None: no servers, pay per request | Tasks on AWS. The default background job queue |
| RabbitMQ | Broker with exchanges and queues, push | Per queue, one consumer | Until acked | A cluster; quorum queues for safety | Rich routing, per-message TTL and priorities, on-premises |
| Apache Kafka | Partitioned log, pull | Per partition | 7 days default, any length | A cluster, or a managed service | Event streams, many consumer groups, replay, 100 MB/s and up |
| Amazon Kinesis Data Streams | Managed partitioned log (shards) | Per shard | 24 hours default, 365 max | Managed; you size shards | Streams on AWS without running Kafka, at moderate volume |
Go deeperOther brokers you may hear about2 min
- Google Cloud Pub/Sub: managed pub/sub, with per-message acks and ack deadlines (a visibility timeout). Optional ordering keys. And replay, by seeking a subscription to a time.
- Azure Service Bus: a managed work queue with sessions for per-key order, dead-lettering and scheduled messages.
- Redis Streams: a log inside Redis with consumer groups and per-entry acks. Fast and simple, bounded by memory, and only as durable as the Redis setup.
- NATS JetStream: a lightweight log and queue in one binary.
- Apache Pulsar: a log that separates serving brokers from storage (BookKeeper) and supports both stream and queue subscriptions on one topic.
- Kafka share groups (KIP-932, “queues for Kafka”): queue semantics on a Kafka topic, with per-record acks, delivery counts and more consumers than partitions. Early access in Kafka 4.0 and preview in 4.1, so check the current release notes before relying on it.
Delivery guarantee
Section titled “Delivery guarantee”| Option | Risk left | When it wins |
|---|---|---|
| At most once (ack first) | Loses a message on every badly timed crash | Telemetry where a gap is fine |
| At least once + idempotent consumer | Side effects in systems without idempotency keys | The default for anything that matters |
| Broker transactions (Kafka EOS) | Anything outside the broker | Stream processing from topic to topic |
Publishing from a database write
Section titled “Publishing from a database write”| Option | How | Lost events | Duplicates | When it wins |
|---|---|---|---|---|
| Dual write | Commit, then publish | On a crash | On retries | Only where losing an event is acceptable |
| Outbox, polling relay | Outbox row in the same transaction; a relay polls | None | Yes | Most services: simple, any database |
| Outbox with CDC | The same row, published from the WAL by Debezium or similar | None | Yes | High volume, low delay, a team that already runs CDC |
| Listen to yourself | Publish first; the service consumes its own event to write the database | None | Yes | Event-sourced services where the log is the source of truth |
Choosing a partition count
Section titled “Choosing a partition count”A log’s partition count caps both throughput and consumer parallelism, and changing it later remaps keys. Size it from three numbers:
- Write throughput. Target 50 MB/s, and a partition sustains about 10 MB/s of produce in your own tests: at least 5 partitions.
- Consumer throughput. This is usually the binding one. A consumer that writes each event to a database processes 2 MB/s per partition: 25 partitions.
- Growth. Adding partitions breaks per-key order during the change, so provision for one to two years: 25 today and 2× growth gives 48 or 50.
Slow work changes the answer. 2,000 messages a second at 200 ms each keep 400 in flight (Little’s law). So strictly ordered consumers would need 400 partitions. Some consumers process different keys of one partition in parallel, each key still in order (Confluent’s parallel consumer). That does the same job with 24 partitions and 20 workers each.
Go deeperToo few or too many partitions, and Kinesis shards1 min
| Too few partitions | Too many partitions |
|---|---|
| Consumer parallelism capped below what the load needs | More files, memory and replication traffic per broker |
| A hot key shares its partition with more neighbors | Longer leader elections and rebalances; more open connections |
| Adding them later reorders keys and moves state | Small batches per partition, so lower throughput and higher cost per byte |
Kinesis makes the same choice in shards. Each takes 1 MB/s or 1,000 records a second in, and 2 MB/s out. Resharding splits or merges shards.
How it fails
Section titled “How it fails”| Failure | What breaks | How you see it | Fix |
|---|---|---|---|
| Poison message | A consumer crash-loops; on a log, its partition stops | One message’s receive count climbing; one partition’s lag growing alone | A retry limit, a DLQ, and catching errors per message rather than per batch |
| Consumers slower than producers | The backlog grows without limit, so delay grows without limit | Lag in time, or age of the oldest message, rising steadily | More consumers (up to the partition count), batching, or fix the slow dependency |
| Retention runs out first | A log deletes old segments, a queue deletes old messages: silent data loss | Lag in time approaching retention | Alert at half the retention; longer retention or tiered storage |
| Visibility timeout below processing time | A healthy slow consumer’s message is given to a second one | Receive count above 1 with no errors logged | Timeout at several times p99, heartbeats for long work |
| Rebalance storm | A consumer that takes longer than max.poll.interval.ms (5 min) is evicted, its partitions move, and the new owner repeats the slow batch |
Rebalances per hour; lag spikes on every deploy | Smaller poll batches, cooperative or KIP-848 rebalancing, static membership |
| Hot partition | One key’s traffic exceeds what one consumer handles | One partition’s lag rising while the others are flat | Split the key if its order allows; make that consumer faster |
| Unwatched DLQ | Failures stop being visible; customers simply never get their email | DLQ depth growing for days | Alert on any message in the DLQ, give it an owner and a redrive tool |
Go deeperThree more ways it fails1 min
| Failure | What breaks | How you see it | Fix |
|---|---|---|---|
| Retry storm | A downstream outage makes every consumer retry at once, prolonging it | Error rate and request rate to the dependency rising together | Exponential backoff with jitter, a circuit breaker, pause consumption |
| Relay or CDC stopped | Orders commit, events do not flow | Age of the oldest unsent outbox row; replication slot size | Alarm at 30 s; run the relay with a standby |
| Non-idempotent consumer | Double charges and double emails on every crash, deploy and replay | Duplicate effects with the same message id | A dedupe key in the same transaction as the effect |
The 2,000-order example drawn whole, with the outbox, the log and three consumer groups. Break any part and see what the shopper notices:
Break it: what fails, who notices, what users see
Failure: click any box to break it
The Kafka cluster goes down
What breaks
The relay cannot publish.
Consumers have nothing new to read.
How we notice
Produce errors and under-replicated partitions on the relay and broker dashboards.
What users see
Nothing on checkout: the outbox absorbs the backlog.
Downstream work waits, then drains at full speed.
What we do
Replication factor 3 with min.insync.replicas 2 survives one broker.
A full outage is a growing outbox table, so size its disk for hours of orders.
At scale
Section titled “At scale”Numbers worth knowing:
| Quantity | Typical value |
|---|---|
| SQS send or receive latency | Tens of ms |
| SQS standard queue throughput | Nearly unlimited; scales on its own |
| SQS FIFO throughput | 300 API calls/s per action, 3,000 messages/s with batches of 10; far more in high-throughput mode |
| SQS limits | Visibility 30 s default, 12 h max. Retention 4 days default, 14 max. Delay 15 min max. Long poll 20 s |
| Kafka partition | Tens of MB/s of produce |
Kafka end-to-end latency, one region, acks=all |
About 5 to 20 ms at moderate load |
| Kafka consumer defaults | Session timeout 45 s, max.poll.interval.ms 5 min, max.poll.records 500 |
| Kinesis shard | 1 MB/s or 1,000 records/s in, 2 MB/s out |
| Largest message, by default | About 1 MB in Kafka, 1 MiB in SQS (256 KiB before 2025). Larger payloads go to blob storage |
At 10× (20,000 messages a second), work queues mostly keep working. SQS scales, and consumers are added by autoscaling on queue depth. A log now needs its partition count to have been right. At a few ms of processing per message, a strictly ordered consumer handles a few hundred messages a second per partition. So 20,000 needs about 64 partitions, or parallel processing within each. Dedupe tables grow by 1.7 billion rows a day. They need time-based partitions, dropped after the longest possible redelivery window, a few days.
At 100× (200,000 messages a second, about 200 MB/s at 1 KB each), one Kafka cluster still copes, on 6 to 12 brokers. But costs and blast radius dominate. With replication factor 3 across zones, every byte written crosses zones twice. Cross-zone transfer, at about $0.01 per GB each way, becomes one of the largest lines on the bill. Consumers fetching from a replica in their own zone cut the read side. Clusters split by domain, so one runaway producer cannot slow everyone. And old segments move to object storage (tiered storage).
What to monitor:
- Lag in time per consumer group and partition, or the age of the oldest message on a queue. 10,000 messages behind is nothing at 50,000 a second and an hour at 3 a second.
- DLQ depth, alarmed on any message, with the error that sent it there.
- Redelivery rate (receive counts above 1), error rate and rebalances per consumer group.
- Produce errors and latency, and under-replicated partitions on the brokers.
- Outbox health: age of the oldest unsent row, CDC slot size.
- Retention headroom: the worst lag in any group against the topic’s retention.
In the interview
Section titled “In the interview”Interviewer asksProduct says every order confirmation email must go out exactly once. What do you build?
Exactly-once delivery is not available from any broker, because an ack can be lost, so I build at least once with an idempotent consumer. The order and an outbox row commit in one transaction, a relay publishes to the queue, and the email worker acks only after sending. The worker records the event id in a sent-emails table and skips ids it has seen. The one remaining window is a crash between the provider accepting the email and the worker recording it. If the provider takes an idempotency key, I pass the event id and the window closes. Otherwise the contract I offer product is a rare duplicate, never a lost email.
Interviewer asksConsumer lag on the billing topic keeps growing. Walk me through it.
First, all partitions, or one? One partition growing alone is a hot key or a poison message. Check per-key rates, and whether the committed offset is stuck on one record. All partitions growing means consumers are slower than producers. Usually that’s because of a slow database they call. If consumers are fewer than partitions, add them. If equal, adding more does nothing. So batch the database writes, or process keys in parallel within a partition. Also check rebalances. A consumer evicted for exceeding the poll interval repeats its batch. Meanwhile, I compare lag in time with retention, because that is when lag turns into data loss.
Seen in
Section titled “Seen in”- Message queue: the broker itself, from partitions to replication.
- Payment system: an outbox in the ledger transaction, events keyed by payment id.
- Notification system: Kafka with
acks=allafter dedupe, delay topics for retries, a lane per channel. - News feed: post events keyed by author drive fan-out workers, redelivered when an offset was not committed.
- Chat: events partitioned by conversation for ordered fan-out, with order itself from sequence numbers.
- Web crawler: small pointers on Kafka, pages in WARC files.
- Ad click aggregator: clicks acknowledged by two zones before the redirect, then replayable.
- Top-K: views keyed by item, with Kafka offsets checkpointed alongside the counts.
- Job scheduler: at-least-once runs, a dead-letter state, and an outbox streamed by CDC.
- Instagram: a media queue, with video on its own queue so photos never wait.
- Video streaming: a job queue of wake-ups over a task table, and views on Kafka.
- Ticket booking, hotel reservation and ride hailing: an outbox, so a broker outage never blocks a booking or a trip.
- Leaderboard: an outbox and absolute totals, so repeats are harmless.
- Proximity service and distributed cache: CDC into Kafka keeps an index or a cache in step with the database.
- Typeahead and URL shortener: query and click logs on Kafka, off the request path.
- Stock exchange: the counter-example, no broker on the order path, Kafka only for clearing.
The full design, stage by stage
Stage 3 of 3
A log for fan-out and replay
Why this stage
Inventory and analytics want the same order events, and analytics needs to reprocess last week after fixing a bug.
What we add
The relay publishes to a Kafka topic keyed by order id: 12 partitions, 7 days of retention.
Each team is its own consumer group with its own offsets, so each reads every event at its own pace.
What it costs
Partitions sized up front, order kept only per order id, a cluster to run or pay for, and every consumer group must be idempotent.
Why. Decoupling, buffering bursts, retries. Paid for with delay, failures that surface elsewhere, and duplicates. Anything the response depends on stays synchronous.
Work queue. Receive hides the message, ack deletes it, no ack means redelivery after the visibility timeout. A receive limit moves poison messages to a DLQ. Redelivery reorders.
Log. Messages kept for retention, offsets per partition per consumer group, replay by resetting offsets, at most one consumer per partition in a group.
Order. Per key, through partitions or FIFO groups. Costs head-of-line blocking, a parallelism cap and hot keys.
Guarantees. Ack first: at most once. Ack after: at least once. Plus an idempotent consumer: effectively once. Exactly-once delivery does not exist. Exactly-once effects do.
Outbox. Event row in the same transaction as the change, published by a relay or CDC. No lost events, some duplicates.
Numbers. SQS visibility 30 s default, 12 h max, retention up to 14 days. Kafka retention 7 days default. Kinesis shard 1 MB/s in, 2 MB/s out. Lag measured in time.
Further reading
Section titled “Further reading”- On this site: Design a message queue, for the broker’s internals. Distributed transactions, for sagas built on events. Partitioning, for how keys map to partitions. And replication, for the change streams that CDC reads.
- Martin Kleppmann, Designing Data-Intensive Applications, chapter 11 (Stream Processing): logs versus queues, and exactly-once in practice.
- Apache Kafka documentation, Design: delivery semantics, the idempotent producer and consumer positions, from the source.
- Chris Richardson, Pattern: Transactional outbox: the pattern, its relay variants and related patterns.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.