Message Queues & Streams
Why systems put a broker in the middle, and how to use one without losing or duplicating work: queues vs logs (RabbitMQ vs Kafka), acks and redelivery, dead-letter queues, ordering by key, consumer groups and lag, routing and retention, real Redis Streams code, and what “exactly-once” really means.
The 30-second version
Sooner or later one program needs to tell another “this happened, please deal with it” — and it shouldn't have to wait, or fail, just because the other program is slow or down. That's what message queues and streams are for.
Why put something in the middle?
Imagine a sign-up page. After saving the new user it must send a welcome email, and talking to the email provider takes about 300 ms. You could call the email service directly — or publish a message “send a welcome email to ana@example.com” and let an email worker do it later. Compare the two:
That one change buys four things:
Decoupling
Async work
Absorbing bursts
And a fourth: Fan-out. One “order paid” message can feed the email service, the invoice service and analytics, and next month a fourth service can start listening without changing the producer at all. Inside one program this is the Observer / pub/sub pattern (Behavioral patterns §03); a broker does the same thing across machines.
Those are the two families of brokers: queues (RabbitMQ, Amazon SQS) and logs, often called streams (Kafka, Redis Streams). §02 puts them side by side.
What can go wrong (and what this page covers)
Putting a broker in the middle turns one function call into a little distributed system: several programs on several machines, any of which can crash at any moment. That raises the questions the rest of this page answers:
- Did it happen? A consumer can crash halfway through a message. Will the message be lost, or done twice? (§03 acks, §04 poison messages)
- In what order? “Paid” must not be processed before “created”. (§05)
- How do I scale it? More consumers, and how far behind they are. (§06)
- Which tool, and how? RabbitMQ routing, Kafka retention, real code. (§07–§09)
- Can I get exactly-once? What's really on offer. (§10) Then production advice, pitfalls, a quiz and a cheat sheet (§11–§14).
Two families: queues and logs
The queue: work to be done
In a queue broker like RabbitMQ or Amazon SQS, a message waits in a first-in, first-out line. When a consumer takes it and finishes, the consumer sends an ack (“done”) and the broker deletes the message.
If several consumers read the same queue, they share the work: each message goes to just one of them. That's called competing consumers, and it's how you scale a queue: if one email worker can't keep up, start three.
The log: a record of what happened
In a log broker like Kafka or Redis Streams, the broker keeps an append-only log: new messages are added at the end and numbered 0, 1, 2… That number is the message's offset.
Reading doesn't delete anything. Each reader just remembers the offset it has reached — a bookmark. So two services can each read every message, at their own speed, and a service can move its bookmark back and read old messages again: a replay. Messages are only deleted by retention: “keep 7 days”, whether or not anyone read them.
Try it
Produce a few messages, then compare the two sides. On the queue side, A and B are two workers sharing the load. On the log side, A and B are two different services (say, email and analytics) reading the same log:
Things to notice:
- Queue: every message is handled by A or B, never both, and then it's gone. Replay has nothing to work with.
- Log: A and B each see every message. Their bookmarks move independently, and A can rewind to offset 0.
- A log can also share work between several copies of one service — that's a consumer group, and §06 is all about it. A queue can also deliver to several services — by giving each service its own queue (§07).
Side by side
| Queue (RabbitMQ, SQS) | Log / stream (Kafka, Redis Streams, Amazon Kinesis) | |
|---|---|---|
| After a message is handled | Deleted | Kept until retention removes it |
| What the broker tracks | Which messages are waiting and which are in progress | Just the log; each group stores its own offset |
| Several services want every message | One queue per service, filled by the broker (§07) | Each service is its own consumer group; nothing to set up |
| Read old messages again | ✗ gone | ✓ move the offset back |
| Unit of order and parallelism | The queue | The partition (§05) |
| Typical use | Jobs and tasks: “send this email”, “resize this image” | Events and history: “order 42 was paid”, clickstreams, change data capture |
| Mental model | Restaurant ticket rail | Newspaper archive |
Acks, redelivery and at-least-once
Delivered is not the same as done
When a broker hands a message to a consumer, it can't know whether the consumer will finish. The consumer might crash, lose its network, or hit a bug halfway through. So the broker keeps the message, marked unacked (“in progress with consumer W1”), until it hears back:
- ack — “finished”. The broker deletes the message (queue) or the consumer moves its offset forward (log).
- nack / reject — “I failed”. The consumer chooses: put it back to be retried, or give up on it (§04).
- silence — the consumer's connection drops, or a timeout runs out. The broker assumes the worst and puts the message back for someone else. That's a redelivery.
Step through the same crash twice — once with the ack after the work, once with the broker's autoAck mode, where the broker treats a message as done the moment it sends it:
1msgs, err := ch.ConsumeWithContext(ctx, "emails", "",2 false, // autoAck off3 false, false, false, nil)4for d := range msgs {5 sendEmail(d.Body) // 1. work6 d.Ack(false) // 2. then ack7}
emails queue. autoAck is off, so the broker keeps each message until a consumer explicitly acks it.The three delivery promises
Where you put the ack picks one of these:
| Promise | How you get it | A crash in the middle means… | Good for |
|---|---|---|---|
| At-most-once | Ack before the work (or autoAck) | The message is lost | Metrics samples, live positions — data the next message replaces |
| At-least-once | Ack after the work | The message is processed again | Almost everything: the default you should choose |
| Exactly-once | Not available as a delivery promise over a network | — | You get the effect with at-least-once + an idempotent consumer (§10) |
Why can't the broker just promise exactly-once? Look at step 4 of the stepper. The email was sent, and then the crash happened before the ack. The broker sees exactly the same thing — “no ack” — whether the consumer crashed before sending the email or after. It has to choose between maybe-lose and maybe-repeat. The safe choice is repeat.
How each broker notices a silent consumer
| Broker | “No ack” is detected by… | Then |
|---|---|---|
| RabbitMQ | The consumer's TCP connection or channel closing (missed heartbeats close it); also a per-delivery timeout, 30 minutes by default | Unacked messages go back in the queue with redelivered = true |
| Amazon SQS | A visibility timeout: a received message is hidden for, say, 30 s. If the consumer doesn't delete it in time, it reappears | Delivered again; ApproximateReceiveCount goes up |
| Kafka | Nothing per message. The group only knows the last committed offset | After a crash, the partition's new owner restarts from the last commit and re-reads everything after it |
| Redis Streams | The group's pending entries list | Someone must claim old pending entries with XAUTOCLAIM (§08) |
Kafka's version: committing offsets
Kafka has no per-message ack. (A Kafka topic is split into partitions — separate logs — and in a consumer group each partition is read by exactly one member; §05 and §06 explain both.) A consumer commits an offset — “my group has finished everything before offset 1,042 in partition 3”. The rule is the same as acking: commit after the work. Commit before it and a crash skips messages (at-most-once); commit after and a crash re-reads a few (at-least-once).
Kafka remembers only that committed number. Where the consumer had actually read up to lives in its memory and dies with it. Try both commit points:
One trap: most Kafka libraries commit for you, and some do it before your code has finished. kafkajs commits once your handler has returned for each batch — safe, if the handler awaits all its work. kafka-go's simplest read function commits each message before handing it to you. §09 shows the safe pattern in both libraries.
Prefetch: how many unacked messages at once
Sending one message, waiting for the ack, then sending the next would waste a network round trip per message. So brokers push several ahead. Prefetch (RabbitMQ calls it QoS) caps how many unacked messages one consumer may hold:
- Too low (1): each consumer idles for a round trip between messages. Safe and fair, but slow for quick jobs.
- Too high (unlimited — the default!): one consumer grabs thousands of messages it can't process soon, while other consumers sit idle. If it crashes, all of them are redelivered at once.
- A sensible start: about the number of messages one consumer processes in a couple of round trips — often 10–100 for fast jobs, 1–5 for slow ones. Then measure.
Prefetch is also backpressure between the broker and one consumer: a busy consumer simply isn't sent more.
Poison messages and dead-letter queues
Two kinds of failure
When a consumer fails on a message, ask why:
- Transient — the database was briefly down, a call timed out, a rate limit said “slow down”. Trying again in a few seconds will probably work.
- Permanent — the body is broken JSON, the order refers to a customer that doesn't exist, the code has a bug for this one input. Trying again will fail in exactly the same way. A message like this is a poison message.
The consumer usually can't tell which one it is. So the standard policy covers both: retry a limited number of times, then give up and park the message somewhere safe. That “somewhere” is a dead-letter queue (DLQ).
One piece of RabbitMQ vocabulary first, because the stepper below uses it. Producers don't publish straight into a queue: they publish to an exchange, a router that copies each message into the queues bound to it (§07 covers the routing rules). A dead-letter exchange is simply the router a queue hands its failed messages to.
dlx, which copies it into orders.dlq.1await ch.assertQueue("orders", { durable: true });2await ch.prefetch(1); // one unacked msg at a time34await ch.consume("orders", async (msg) => {5 try {6 await handle(JSON.parse(msg.content.toString()));7 ch.ack(msg);8 } catch (err) {9 ch.nack(msg, false, true); // requeue10 }11});
prefetch(1) means this consumer holds at most one unacked message at a time.Compare the two modes:
- Retry forever: the poison message comes straight back and fails again, thousands of times. One consumer is stuck on it at 100% CPU; with a single consumer that may hold only one unacked message (prefetch 1, §03), every message behind it waits — head-of-line blocking.
- Limit + DLQ: after a few attempts, the broker moves it aside. Three quick failures, then everything else flows. A person looks at the DLQ later.
The same idea in every broker
| Broker | How you cap attempts | Where failures go |
|---|---|---|
| RabbitMQ | Quorum queue x-delivery-limit (RabbitMQ 4.0+ sets a default of 20) | A dead-letter exchange (x-dead-letter-exchange) routes them to a queue you bind, e.g. orders.dlq. Messages also get dead-lettered when nacked without requeue, or when their TTL runs out |
| Amazon SQS | A redrive policy on the queue: maxReceiveCount: 5 | The DLQ named in the policy, after the receive count goes above 5 |
| Kafka | Nothing built in. The log can't skip a message for you; your consumer decides | Your consumer publishes the failure to another topic, e.g. orders.dlq, then commits the offset and moves on |
| Redis Streams | Check the delivery count XPENDING reports for each entry | Your code XADDs it to another stream and XACKs the original |
Back off between attempts
Retrying a transient failure immediately usually fails again, because whatever was broken a millisecond ago is still broken — and a thousand consumers hammering a recovering database will knock it straight back down. Wait longer after each failure: exponential backoff with jitter (1 s, 2 s, 4 s… plus a random bit). Async / Await §11 shows the retry loop and Worker Pools §07 covers retry budgets.
Where the wait happens matters with a broker:
- In the consumer (sleep, then retry in memory): simple, but the message is held unacked and blocks its slot — and its partition, in Kafka.
- In the broker: SQS can delay redelivery by changing the visibility timeout; RabbitMQ can dead-letter into a “wait” queue with a message TTL, which dead-letters the message back into the main queue when it expires. More moving parts, but nothing blocks.
A DLQ is a to-do list, not a bin
A DLQ nobody looks at is just a slower way to lose data. Three habits make it useful:
- Alert on it. A gauge for DLQ depth (messages waiting in it), and an alert when it's above zero for a few minutes. §11 has the full list of metrics.
- Keep the evidence. Store the error message, the attempt count and the original queue with the dead-lettered message (in headers), so whoever investigates doesn't have to reproduce it. RabbitMQ adds an
x-deathheader automatically. - Have a redrive path. Once the bug is fixed, move the messages back to the main queue to be processed: a redrive. SQS has a button and an API for it; with RabbitMQ and Kafka it's a small script or tool you keep ready. Redriven messages arrive late and out of order, so the consumer must tolerate that.
Ordering: per partition, per key
Why order matters, and why it's hard
Order 42 produces three events: created, paid, shipped. If the shipping service handles shipped before created, it may reject it (“unknown order”) or overwrite newer data with older data.
Within one log partition, order is easy: messages are stored and read in offset order. The trouble is that a single partition is read by a single consumer — so one partition means no parallelism at all. To get throughput, Kafka splits a topic into many partitions, read by many consumers at the same time. And across partitions there is no order at all.
The key decides the partition
The way out: you rarely need global order — only order per thing: per order, per customer, per bank account. So every Kafka message can carry a key, and the producer picks the partition like this:
partition = hash(key) % numberOfPartitions // % = remainder after divisionSame key → same hash → same partition → read by the same consumer, in order. Different keys spread out over all partitions, so you still get parallelism. Try it — the widget uses Kafka's real default hash function (murmur2):
Three experiments:
- Key = customer. Produce alice, carol, frank and oscar a few times each, then step. Every customer's messages stay in one partition and are handled in order (✓), while C1 and C2 work in parallel.
- No key. Switch the toggle, produce alice three times, then step twice. Round-robin (taking turns: p0, p1, p2, p0…) puts alice#1, #2 and #3 in three different partitions; C2 handles #3 while C1 is still busy elsewhere, and #2 comes after #3 (✗). (kafkajs and kafka-go take turns like this for unkeyed messages; the Java client sends a whole batch to one partition, then moves on. Either way, unkeyed messages are spread out.)
- Add a partition. Back on “key = customer”, produce frank twice, press “add partition 3”, produce frank again, then step twice.
% 3became% 4, so frank's new message lands in partition 3 (owned by C2) while his older ones are still waiting in partition 1 (owned by C1) — out of order.
What breaks ordering (even with keys)
| Cause | What happens | Fix |
|---|---|---|
| No key / random key | Related messages spread over partitions | Key by the entity whose order matters (order id, account id) |
| Changing the partition count | hash % n changes for most keys, so new messages go to a new partition while old ones wait in the old one | Choose the partition count up front; if you must grow, drain first (stop producers until consumers have processed everything already written) or accept a short window of disorder |
| Parallel processing inside a consumer | You read in order, then hand messages to 10 goroutines or Promise.all — they finish in any order | Process one key at a time: e.g. route each key to a fixed worker (hash(key) % workers) inside the consumer |
| Retries that skip ahead | Message 5 fails, goes to a retry queue, message 6 succeeds first | Retry in place (blocking the partition), or make handlers accept out-of-order events (§10: version numbers) |
And with a queue broker, order is weaker still. A RabbitMQ queue hands messages out in order, but with two competing consumers, message 2 can finish before message 1 — and a redelivery puts message 1 back after others have already been handled. If you need strict order from a queue, you need a single consumer (RabbitMQ has a single active consumer setting that keeps a standby ready), or SQS FIFO queues with a MessageGroupId, which work just like a key: order per group, parallelism across groups.
Choosing a key
A good key has two properties at once:
- Everything that must stay in order shares it. Usually that's the id of the entity the events are about.
- There are many distinct values, spread fairly evenly. If one key carries 40% of the traffic (one giant customer, or a
nullkey with a client that sends nulls to one partition), its partition becomes a hot partition: one consumer drowns while the rest are idle, and adding consumers doesn't help (§06).
Consumer groups, rebalancing and lag
How a group shares the work
In §02 each log reader had its own bookmark. A consumer group is several copies of one service sharing one set of bookmarks: one committed offset per partition. The broker splits the partitions among the members:
- 6 partitions, 2 consumers → 3 partitions each.
- 6 partitions, 4 consumers → 2, 2, 1, 1.
- 6 partitions, 8 consumers → 6 consumers get one each; 2 sit idle. A partition is never split between two members of the same group — that's what keeps per-key order (§05).
A different service (say, analytics) uses a different group name and gets its own set of offsets, so it reads every message independently. That's how one topic feeds many services.
Rebalancing: the pause nobody expects
When a member joins, leaves, or is declared dead (it stopped sending heartbeats for longer than the session timeout, or took too long between polls — a Kafka consumer asks the broker for messages in a loop, and each request is a poll), the group must re-share the partitions: a rebalance. In the classic eager protocol, every member stops, gives up all its partitions, and waits for the new assignment — even members whose partitions don't change.
Experiments:
- Run at 300/s with 2 consumers. The group keeps up; lag stays near zero.
- Switch to 600/s. Each consumer owns 3 partitions = 300/s of work but can only do 200/s, so lag climbs. Press + consumer once: during the ≈3 s rebalance nothing is processed (⏸), then 3 consumers × 200/s match the 600/s and lag stops growing — but it doesn't shrink, because there's no spare capacity to drain the backlog (including what piled up during the pause). Running at exactly 100% means never catching up. Keep adding: only partitions whose consumer has spare capacity drain, and at 6 consumers they all do.
- Try 900/s with 5 consumers: one consumer gets two partitions and falls behind while the others are fine. 6 consumers fix it; 7 and 8 add nothing but idle consumers.
- Try 1300/s: each partition alone gets ~217/s, more than one consumer can do. Add all 8 consumers — no help. You need more partitions or faster consumers.
- Flip to cooperative and add a consumer: only the partitions that change owner pause.
Lag: are we keeping up?
Consumer lag is the gap between the newest offset in a partition and the group's committed offset: messages written but not yet processed. It's the health metric for a log consumer:
- Lag near zero, with spikes that drain — healthy. The broker absorbed a burst.
- Lag growing steadily — consumers are slower than producers. It will never recover by itself.
- Lag flat but high — you're keeping up with new messages, but everything is late by a constant amount.
Lag in messages is hard to judge (is 50,000 a lot?). Convert it to time with Little's Law from Worker Pools §03: a new message waits roughly
wait ≈ lag ÷ processing rate // 60,000 messages ÷ 2,000/s ≈ 30 sMany tools report the time directly as age of the oldest unprocessed message (SQS: ApproximateAgeOfOldestMessage). Alert on that: “emails are more than 5 minutes late” means something to a human; “lag is 50,000” doesn't.
Shock absorber or hiding place?
A broker's backlog is a feature when it absorbs a burst: 5,000 orders arrive in one minute, workers clear them in the next five. It becomes a trap when work arrives faster than it's done on average. Then the backlog grows without limit, every message waits longer than the one before, and the broker hides the overload from the producers — they keep getting fast “published ✓” answers while the system falls hours behind.
Worker Pools §04–§05 shows why this happens to any queue, and why latency explodes well before 100% busy. For brokers the defences are:
- Capacity: at peak, consumers must be able to drain faster than producers write — leave headroom (≈30%) so backlogs from bursts and rebalances can drain.
- Limits: cap the queue's length or the messages' age so overload shows up as errors you can see, not as hidden delay (§07: max-length and TTL).
- Alerts: on lag in seconds and on a lag trend, not just a high threshold.
Routing, retention and limits
RabbitMQ: exchanges, bindings, routing keys
A RabbitMQ producer never writes into a queue directly. It publishes to an exchange with a routing key such as order.created.eu. The exchange stores nothing; it looks at its bindings — rules like “order.created.* → queue email” — and drops a copy into every queue that matches. Each queue then works like §02: competing consumers, deleted on ack.
* matches exactly one word, # matches zero or more words. “order.created.eu” lands in 3 queues: email, billing, audit.| Exchange type | A queue gets the message when… | Typical use |
|---|---|---|
| direct | its binding key equals the routing key exactly | Route jobs by kind: resize, transcode |
| topic | its binding pattern matches: * = exactly one word, # = zero or more words | Events by category: order.*.eu, payment.# |
| fanout | always (the key is ignored) | Broadcast: every subscribing service gets everything |
| (default) | the routing key equals the queue's name | What sendToQueue("emails", …) uses under the hood |
So “several services each get every message” in RabbitMQ means one queue per service, all bound to the same fanout or topic exchange. Each service's queue fills independently; a slow service only backs up its own queue.
Kafka: topics, retention and compaction
Kafka has no routing step: a producer names a topic, and the key picks the partition (§05). Filtering happens in the consumer — every group reads the whole topic and skips what it doesn't need. Instead, Kafka's interesting knob is how long messages stay:
- Delete by time or size — the default.
retention.ms(7 days by default) orretention.bytesper partition. Kafka stores each partition as a series of files called segments; old segments are deleted whether or not anyone read them. A consumer that's down for longer than the retention period comes back to find its offset gone, and starts from the oldest or newest message depending on itsauto.offset.resetsetting — silently skipping data if that's “latest”. - Compact (
cleanup.policy=compact) — log compaction keeps at least the newest message for each key, forever. The topic stops being history and becomes a table of “current value per key” that any new service can load by reading from offset 0.
To delete a key from a compacted topic, you publish a tombstone: the key with a null value. Compaction is how Kafka stores consumer group offsets internally, and how Kafka's own tools keep their state: Kafka Connect (which copies data between Kafka and databases) and Kafka Streams (a library for processing streams).
Limits: making overload visible
A queue without limits is a memory leak that hasn't happened yet (Worker Pools §04). Queue brokers give you the same three tools as an in-process queue:
| Limit | RabbitMQ | Amazon SQS | What it protects |
|---|---|---|---|
| Max length | x-max-length / x-max-length-bytes; when full, x-overflow = drop-head (drop oldest, the default), reject-publish (tell the producer no) or reject-publish-dlx | None (practically unlimited), but there's a 14-day maximum retention | The broker's memory and disk; turns overload into errors producers can see |
| Message TTL | x-message-ttl per queue, or expiration per message; expired messages are dropped or dead-lettered | MessageRetentionPeriod (1 minute to 14 days) | Stale work: a “your code is 123456” SMS is useless after 10 minutes |
| Priority | x-max-priority on a classic queue (RabbitMQ's original, non-replicated queue type); higher-priority messages jump ahead | No priorities — use separate queues | Urgent work behind a backlog of routine work |
Hands-on: a real stream with Redis Streams
Kafka and RabbitMQ need a cluster to try anything. Redis Streams need only Redis, which many teams already run. It's a genuinely useful broker for moderate volumes, and it makes the concepts visible. Everything below was run against Redis 7.0; Redis 6.2 or newer has all of it.
The commands, one at a time
Start a server (redis-server), open redis-cli in another terminal, and follow along. The stream is called orders; the group of shipping workers is called shipping.
1. Append three entries. XADD orders * … adds an entry at the end. * asks Redis to choose the id, which is the current time in milliseconds plus a counter — so ids always increase:
> XADD orders * id 1 item book
"1793363659143-0"
> XADD orders * id 2 item pen
"1793363659151-0"
> XADD orders * id 3 item mug
"1793363659161-0"2. Create a consumer group. The final 0 means “this group starts from the very first entry” ($ would mean “only entries added from now on”):
> XGROUP CREATE orders shipping 0
OK3. Two consumers share the work. XREADGROUP GROUP shipping alice reads as consumer alice of group shipping. The > means “entries never delivered to anyone in this group”. Alice asks for 2 and gets entries 1 and 2; Bob then gets the only one left:
> XREADGROUP GROUP shipping alice COUNT 2 STREAMS orders >
1) 1) "orders"
2) 1) 1) "1793363659143-0"
2) 1) "id"
2) "1"
3) "item"
4) "book"
2) 1) "1793363659151-0"
2) 1) "id"
2) "2"
3) "item"
4) "pen"
> XREADGROUP GROUP shipping bob COUNT 2 STREAMS orders >
1) 1) "orders"
2) 1) 1) "1793363659161-0"
2) 1) "id"
2) "3"
3) "item"
4) "mug"That's competing consumers inside a group (§06), with no partitions to plan: Redis hands each entry to whoever asks next. The price: entries handled by alice and bob can finish in any order (§05).
4. Ack, then look at what's still pending. Alice finishes entry 1 and Bob finishes entry 3. XPENDING shows the group's pending entries list — delivered but not acked:
> XACK orders shipping 1793363659143-0
(integer) 1
> XACK orders shipping 1793363659161-0
(integer) 1
> XPENDING orders shipping
1) (integer) 1
2) "1793363659151-0"
3) "1793363659151-0"
4) 1) 1) "alice"
2) "1"Read it as: 1 entry pending; the oldest and newest pending ids are both …151-0 (entry 2); alice holds 1 of them.
5. Alice crashed. Bob takes over her stuck entry. Redis won't redeliver by itself — someone must claim it. XAUTOCLAIM orders shipping bob 1000 0 means “give bob every pending entry that's been idle for at least 1000 ms, scanning from the start”:
> XAUTOCLAIM orders shipping bob 1000 0
1) "0-0"
2) 1) 1) "1793363659151-0"
2) 1) "id"
2) "2"
3) "item"
4) "pen"
3) (empty array)
> XACK orders shipping 1793363659151-0
(integer) 1"0-0" means the scan reached the end of the pending list. This is §03's redelivery, done by hand: if alice had sent the parcel before crashing, bob sends it again. At-least-once.
6. A second group reads everything again. Acks didn't delete anything. A new group — say analytics — starts at 0 and sees all three entries, and the stream still holds 3:
> XGROUP CREATE orders analytics 0
OK
> XREADGROUP GROUP analytics carol COUNT 10 STREAMS orders >
1) 1) "orders"
2) 1) 1) "1793363659143-0"
… (entries 1, 2 and 3, same as above)
> XLEN orders
(integer) 3That's the log side of §02: independent groups, each with its own position, and replay for free. Old entries go away only when you trim the stream — for example XADD orders MAXLEN ~ 100000 * … keeps roughly the newest 100,000 (the ~ lets Redis trim in efficient chunks). That's Redis's version of retention.
The same thing in Go and JavaScript
A producer that appends three orders, then a consumer loop that reads as one member of the group, acks after the work, and stops cleanly on Ctrl+C. Run two copies (with different consumer names) and they share the entries.
package main
import (
"context"
"errors"
"fmt"
"log"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
// ctx is cancelled when the process gets Ctrl+C (SIGINT) or SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Producer: append three orders to the stream.
for i, item := range []string{"book", "pen", "mug"} {
id, err := rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "orders",
Values: map[string]any{"id": i + 1, "item": item},
}).Result()
if err != nil {
log.Fatal(err)
}
fmt.Println("added", id)
}
// Create the group once. "0" = start from the oldest entry. MKSTREAM creates the stream if missing.
err := rdb.XGroupCreateMkStream(ctx, "orders", "shipping", "0").Err()
if err != nil && !redis.HasErrorPrefix(err, "BUSYGROUP") { // BUSYGROUP = it already exists
log.Fatal(err)
}
consume(ctx, rdb, "alice")
}
func consume(ctx context.Context, rdb *redis.Client, me string) {
for ctx.Err() == nil { // loop until Ctrl+C
res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: "shipping",
Consumer: me,
Streams: []string{"orders", ">"}, // ">" = never delivered to this group
Count: 10,
Block: 2 * time.Second, // wait up to 2 s for new entries
}).Result()
if errors.Is(err, redis.Nil) {
continue // timed out, nothing new
}
if err != nil {
if ctx.Err() != nil {
return // shutting down
}
log.Println("read:", err)
time.Sleep(time.Second)
continue
}
for _, msg := range res[0].Messages {
fmt.Printf("%s ships order %v (%v)\n", me, msg.Values["id"], msg.Values["item"])
// ...do the real work here...
if err := rdb.XAck(ctx, "orders", "shipping", msg.ID).Err(); err != nil { // ← ack AFTER the work
log.Println("ack:", err)
}
}
}
}Real output (Go with go-redis v9.18, Node 22 with the redis package v6.3; Ctrl+C pressed after the last line):
added 1793364496741-0
added 1793364496741-1
added 1793364496741-2
alice ships order 1 (book)
alice ships order 2 (pen)
alice ships order 3 (mug)
^CKafka and RabbitMQ clients in Go and JavaScript
Kafka
The producer sends two events for the same customer, so they share a key and stay in order (§05). The consumer is one member of the shipping group; it processes a message, then commits its offset (§03).
import "github.com/segmentio/kafka-go"
func produce(ctx context.Context) error {
w := &kafka.Writer{
Addr: kafka.TCP("localhost:9092"),
Topic: "orders",
Balancer: &kafka.Murmur2Balancer{}, // ← same key → same partition (Java-compatible)
RequiredAcks: kafka.RequireAll, // wait until all in-sync replicas have it
}
defer w.Close() // flushes anything still buffered
return w.WriteMessages(ctx,
kafka.Message{Key: []byte("customer-42"), Value: []byte(`{"order":1,"status":"paid"}`)},
kafka.Message{Key: []byte("customer-42"), Value: []byte(`{"order":1,"status":"shipped"}`)},
)
}
func consume(ctx context.Context) error {
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"localhost:9092"},
GroupID: "shipping", // ← consumer group: partitions are shared among members
Topic: "orders",
})
defer r.Close() // leaves the group cleanly → partitions move at once
work := context.WithoutCancel(ctx) // finish the message in hand even during shutdown
for {
m, err := r.FetchMessage(ctx) // waits for the next message; does NOT commit
if err != nil {
if errors.Is(err, context.Canceled) {
return nil // shutting down
}
return err
}
if err := handle(work, m); err != nil {
return err // no commit: after a restart we'll read it again
}
if err := r.CommitMessages(work, m); err != nil { // ← commit AFTER the work
return err
}
}
}What the settings mean:
RequiredAcks: RequireAll/acks: -1. Each partition is copied to several brokers (its replicas). One of them, the partition leader, takes the writes; the others (followers) copy from it. Withacks=allthe leader answers only after every in-sync replica has the message, so losing one broker doesn't lose it.acks=1(leader only) is faster but loses messages if the leader dies before followers copy them. Pairacks=allwith the topic settingmin.insync.replicas=2on a 3-replica topic: a write then fails rather than being stored on only one machine. Replication itself is explained in Replication.- Who commits, and when. kafka-go's
ReadMessageis the simpler call, but it commits each message before returning it, so a crash while handling it loses it — that's why the Go code usesFetchMessage+CommitMessages. kafkajs commits automatically onceeachMessagehas returned for the messages of a batch (by default after each batch); that's at-least-once as long as the handler awaits all its work. Start background work without awaiting it and kafkajs commits messages that are still in progress. - The balancer. kafka-go's default ignores keys (§05). kafkajs's default hashes keys with murmur2, matching the Java client.
- Which Go client? kafka-go is simple and popular. franz-go (
github.com/twmb/franz-go) is more complete: an idempotent producer by default, transactions, cooperative rebalancing. Confluent'sconfluent-kafka-gowraps the C library librdkafka. - Which JS client? kafkajs is the most-used pure-JavaScript client, but its releases have slowed since 2023 (2.2.4 is the latest). Confluent's
@confluentinc/kafka-javascriptoffers a KafkaJS-compatible API on top of librdkafka. Check the state of both before you choose.
RabbitMQ
One program that declares a durable queue, publishes one persistent message, and consumes with manual acks and a prefetch of 10.
import amqp "github.com/rabbitmq/amqp091-go"
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
ch, err := conn.Channel() // a lightweight session inside the TCP connection
if err != nil {
log.Fatal(err)
}
defer ch.Close()
// name, durable, autoDelete, exclusive, noWait, args
q, err := ch.QueueDeclare("emails", true, false, false, false, nil)
if err != nil {
log.Fatal(err)
}
// --- publish --- exchange "" = the default exchange: routing key = queue name
if err := ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{
DeliveryMode: amqp.Persistent, // write to disk, not just memory
ContentType: "application/json",
Body: []byte(`{"to":"ana@example.com"}`),
}); err != nil {
log.Fatal(err)
}
// --- consume ---
if err := ch.Qos(10, 0, false); err != nil { // prefetch: at most 10 unacked at a time
log.Fatal(err)
}
msgs, err := ch.ConsumeWithContext(ctx, q.Name, "", false /* autoAck */, false, false, false, nil)
if err != nil {
log.Fatal(err)
}
for d := range msgs { // ends after Ctrl+C: the consumer is cancelled, msgs is closed
if err := sendEmail(d.Body); err != nil {
// Ack/Nack errors are ignored here: they only fail when the channel is
// closing, and then the broker redelivers the message anyway.
d.Nack(false, false) // failed: don't requeue → DLQ if one is set
continue
}
d.Ack(false) // ← ack AFTER the work is done
}
}What the settings mean:
- Durable queue + persistent message. You need both to survive a broker restart: a durable queue without persistent messages comes back empty. For a replicated queue that survives losing a whole broker node, declare a quorum queue (§04).
- A channel is a lightweight session multiplexed over one TCP connection (part of RabbitMQ's protocol, AMQP). The library docs recommend separate connections for publishing and consuming in busy apps, so that the broker slowing a fast publisher down doesn't also stall your acks.
- The JS handler runs concurrently. amqplib doesn't wait for one async callback to finish before calling the next, so up to
prefetch(10) messages are in progress at once — and can finish out of order (§05). Prefetch is your concurrency limit.
Did the broker really get it? Publisher confirms
sendToQueue and PublishWithContext return as soon as the message is written to the socket. If the broker crashes a millisecond later, the message is gone and the producer never knows. Turn on publisher confirms and wait for them whenever losing a message matters:
if err := ch.Confirm(false); err != nil { // put the channel in confirm mode (once)
return err
}
dc, err := ch.PublishWithDeferredConfirmWithContext(ctx, "", "emails", false, false,
amqp.Publishing{DeliveryMode: amqp.Persistent, Body: body})
if err != nil {
return err
}
ok, err := dc.WaitContext(ctx) // ← blocks until the broker says "stored"
if err != nil {
return err
}
if !ok {
return errors.New("broker nacked the message")
}Waiting for each confirm one by one is slow (a round trip per message). Publish a batch, then wait once for all of them. Kafka producers get the same guarantee from acks — WriteMessages and send only resolve once the broker has replied.
Graceful shutdown, in one list
When the process gets SIGTERM (a deploy, a scale-down), the goal is to lose nothing and redo as little as possible. The general recipe is in Worker Pools §09; for a broker consumer it becomes:
- Stop taking new messages — cancel the consumer (RabbitMQ) or stop fetching (Kafka).
- Finish what's in hand, and ack or commit each message as it completes.
- Close the consumer properly. Kafka: the member leaves the group, so its partitions are reassigned immediately instead of after the session timeout (§06). RabbitMQ: closing the channel requeues anything still unacked — nothing is lost, it's just redelivered.
- Give up after a deadline (your platform kills you anyway — Kubernetes after 30 s by default). Anything unfinished will be redelivered: one more reason handlers must be idempotent.
Exactly-once: what's really on offer
Where duplicates (and losses) come from
Follow one “order paid” event from the database to the email it triggers. Things can go wrong in three places:
- The producer's two writes. The order service saves to its database and publishes to the broker. A crash between the two loses the event or announces something that didn't happen.
- The producer's retries. The broker stored the message, but the ack got lost; the producer retries and the message is stored twice.
- The consumer's crash. The consumer did the work but crashed before acking (§03) — or the message was redriven from a DLQ, or replayed. It runs again.
Each has its own fix. Only the last one is fully in your hands, and it covers for the other two.
1. Database + broker: the outbox
You can't make a database write and a broker publish one atomic step. The standard answer is the transactional outbox: in the same database transaction as the business change, insert the event into an outbox table; a separate relay process (or change data capture reading the database log) publishes outbox rows to the broker and marks them sent. Behavioral patterns §10 has a crash simulator for exactly this, and Sagas builds multi-step processes on it.
Note what the outbox guarantees: the event is published at least once. The relay can crash after publishing but before marking the row sent, and publish it again. Duplicates again — handled by fix 3.
2. Producer retries: the idempotent producer
Kafka's idempotent producer numbers each batch per partition, so the broker can drop a retried batch it has already written. It also keeps retries from reordering messages. It's on by default in the Java client since Kafka 3.0 and in franz-go; kafkajs needs idempotent: true; kafka-go doesn't support it. RabbitMQ and SQS have no equivalent (SQS FIFO queues drop repeats with the same MessageDeduplicationId within a 5-minute window).
It only covers the client's own retries within one producer session. A restarted producer, an outbox relay that publishes a row twice, or your code calling send() twice all produce real, distinct duplicates.
3. Kafka → Kafka: transactions
A common Kafka job reads from one topic, computes something, and writes to another: orders in, invoices out. Here a crash can cause a duplicate output: the invoice was written, but the input offset wasn't committed, so the input is processed again.
Kafka transactions close that gap by putting the output messages and the input offset commit in one atomic unit:
const producer = kafka.producer({
transactionalId: "invoicer-1", // stable per instance: if an old copy of this worker is
// still running (it froze and was replaced), Kafka rejects its writes
idempotent: true,
maxInFlightRequests: 1,
});
await consumer.run({
autoCommit: false, // the transaction commits the offset instead
eachMessage: async ({ topic, partition, message }) => {
const tx = await producer.transaction();
try {
await tx.send({ topic: "invoices", messages: [{ key: message.key, value: makeInvoice(message.value) }] });
await tx.sendOffsets({ // "I've consumed up to here" — inside the same transaction
consumerGroupId: "invoicer",
topics: [{ topic, partitions: [{ partition, offset: (BigInt(message.offset) + 1n).toString() }] }],
});
await tx.commit(); // output + offset become visible together, or not at all
} catch (err) {
await tx.abort();
throw err;
}
},
});Downstream consumers must read with isolation.level=read_committed (kafkajs: readUncommitted: false, the default), or they'll see writes from aborted transactions. Kafka Streams and Flink use this machinery when you turn on “exactly-once”.
The honest fine print: this is exactly-once only for state that lives in Kafka. If makeInvoice also sends an email, charges a card or writes to Postgres, those effects are not rolled back when the transaction aborts and retries. Transactions also add latency (each commit is extra round trips), so they're usually committed per batch, not per message as in this short example.
4. The fix that always works: an idempotent consumer
Whatever happened upstream, the consumer can make its effect happen once. The simplest robust version: keep a table of message ids you've processed, and insert the id in the same database transaction as the effect. If the insert finds the id already there, it's a duplicate — skip the work, but still ack.
// handleOnce runs apply at most once per message id: the id and the effect commit together.
func handleOnce(ctx context.Context, db *sql.DB, msgID string, apply func(*sql.Tx) error) (string, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback() // undoes everything unless Commit ran first
res, err := tx.ExecContext(ctx,
`INSERT INTO processed_messages (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, msgID)
if err != nil {
return "", err
}
if n, err := res.RowsAffected(); err != nil {
return "", err
} else if n == 0 { // ← id already there: a duplicate
return "duplicate, skipped", nil
}
if err := apply(tx); err != nil { // the real change, in the SAME transaction
return "", err
}
return "applied", tx.Commit()
}We ran both against PostgreSQL 16, delivering the same message twice to an apply that adds 10 loyalty points (Go with pgx/v5, Node with pg):
applied
duplicate, skipped
ana's points: 10What makes this work, and its limits:
- Which id? It must be the same on every redelivery, and different for different messages. Kafka's
topic-partition-offset(e.g.orders-3-1042for offset 1,042 in partition 3 oforders) works for one consumer group. A business id chosen by the producer (an idempotency key or event id stored in the message) is better: it also catches duplicates the outbox relay or a producer retry created, which have new offsets. - Same transaction, same database. If the effect is in another system — an email, a payment API — you can't commit it together with the id. Then pass the key along (most payment providers accept an idempotency key) or record “started” and “done” states. Idempotency covers these cases.
- Clean up old ids eventually (e.g. after 30 days — longer than any possible redelivery or replay window).
Choosing and running a broker
Which broker?
| RabbitMQ | Kafka | Amazon SQS (+ SNS) | Redis Streams | NATS JetStream | |
|---|---|---|---|---|---|
| Model | Queues + flexible routing (also has streams) | Partitioned log | Managed queue; SNS, AWS's pub/sub service, copies one message to many queues | Log with consumer groups | Streams with queue-like or log-like consumers |
| Replay old messages | Only with RabbitMQ streams | ✓ core feature | ✗ | ✓ until trimmed | ✓ |
| Ordering | Per queue (with one consumer) | Per partition (key) | FIFO queues: per message group | Per stream | Per stream |
| Throughput (rough) | Tens of thousands msg/s per node | Millions msg/s per cluster | Practically unlimited (standard queues) | Limited by one Redis node | Very high |
| Per-message retry / DLQ | ✓ built in | ✗ you build it (§04) | ✓ redrive policy | Manual | ✓ max deliveries |
| Who operates it | You (or a managed service) | You — the heaviest to run (or managed: Amazon MSK, Confluent Cloud) | AWS | You probably already run Redis | You — one small binary |
| Reach for it when… | Task queues, complex routing, per-message retries | Event streams many teams consume, replay, CDC, analytics | You're on AWS and want zero ops | Modest volume, Redis already there | Low latency, edge/IoT, light ops |
The throughput figures are orders of magnitude only; they depend heavily on message size, durability settings and hardware. Most teams hit operational limits — people who know how to run and debug the thing — long before raw throughput limits.
Messages are a public API: schema evolution
The producer and its consumers are deployed separately, by different teams, at different times. With a log, a consumer may even read a message written months ago. So the message format is a contract you change carefully — the same problem gRPC solves for requests (gRPC §05).
The rules that keep old and new code compatible:
- Add fields; never remove or rename one that's in use. New fields get defaults, so old messages still parse. Old consumers ignore fields they don't know (check that your JSON decoder does).
- Never change a field's meaning or type.
amountin cents must not becomeamountin euros. Addamount_eurinstead. - Deploy consumers before producers when a new field matters, so nobody receives something they can't handle.
- Put a type and version in every message (
"type": "order.paid", "version": 2) so a consumer can route or reject what it doesn't understand instead of crashing on it — which would make it a poison message (§04).
Binary formats with schemas — Protobuf, Avro (a similar binary format, common with Kafka) — turn these rules into tooling. A schema registry stores every version of each topic's schema and rejects a producer change that would break compatibility before it ships.
What to monitor
| Metric | Why | Alert when… |
|---|---|---|
| Consumer lag in time (or age of oldest message) | The delay users actually feel (§06) | Above what users tolerate (e.g. emails > 5 min late), or growing for 10+ minutes |
| DLQ depth | Every message there is work that didn't happen (§04) | > 0 for a few minutes |
| Publish errors and confirm latency | Producers can't hand work off; they may be retrying or dropping | Above a small baseline |
| Redelivery rate | Crashing consumers, too-short timeouts, flaky dependencies | Sudden increase |
| Broker health | Disk and memory; under-replicated partitions (Kafka); memory and disk alarms (RabbitMQ blocks publishers when one fires) | Approaching limits |
| Rebalances per hour (Kafka) | Each one pauses processing (§06) | More than deploys explain |
The hidden costs of event-driven systems
Event-driven systems are loosely coupled in the code. That's the point — and it hides some costs:
- Coupling doesn't disappear; it moves into the message format. Ten services parse
order.paid, so changing it is a ten-team migration. Decide who owns each topic and who may publish to it. - The flow becomes invisible. “What happens when an order is paid?” is no longer a function you can read; it's whoever subscribes today. Keep a catalogue of topics, producers and consumers.
- Debugging needs tracing. Put a correlation id (or trace context) in every message's headers and log it everywhere, so you can follow one order across five services and two brokers. Distributed tracing makes this visual — see Observability.
- Eventual consistency becomes user-visible. Right after “Pay”, the order page may still say “pending” because the update travels through a queue. Design the UI for it; Replication §06 shows the same effect with database replicas.
- Not everything should be async. If the caller needs the answer to continue (“is this card valid?”), a direct call with a timeout is simpler, and fails more honestly, than a request/response dance over a broker.
Pitfalls gallery
Acking (or committing) before the work is done
autoAck, kafka-go's ReadMessage, a commit placed at the top of the handler — they all turn a crash into a silently lost message (§03). The broker deleted it, the work never happened, and no error was logged anywhere.
Fix: manual acks, placed after the work succeeds. In Kafka, FetchMessage + CommitMessages (Go) or an eachMessage that awaits all its work before returning (JS).
Background work behind the consumer's back
eachMessage: async ({ message }) => {
processInBackground(message); // ← no await: returns at once
}kafkajs sees the handler resolve immediately, marks the message as done and commits it, while the real work hasn't even started. A crash loses it; a slow dependency means thousands pile up in memory with no backpressure. The Go version is go process(m) followed by CommitMessages.
Fix: await the work. If you need parallelism, add partitions and consumers (§06), raise prefetch (RabbitMQ), or use a bounded pool inside the consumer — and only ack/commit messages once they've finished.
Assuming each message arrives once
UPDATE accounts SET points = points + 10 in a consumer will, one day, add 20. Redelivery after a crash, a lost ack, an outbox relay retry, a DLQ redrive, a replay — each delivers the same message again (§03, §10).
Fix: make the effect idempotent: a processed-ids table in the same transaction, absolute updates (SET status = 'paid'), or an idempotency key at the destination.
Writing to the database and publishing as two separate steps
db.Exec(`UPDATE orders SET status = 'paid' WHERE id = $1`, id)
w.WriteMessages(ctx, orderPaid(id)) // crash between these two lines = nobody ever hearsSwap the order and a crash announces a payment that was never saved. Wrapping both in a database transaction doesn't help — the broker isn't part of it.
Fix: the transactional outbox (§10, Behavioral patterns §10).
No key, or the wrong key
Unkeyed messages are spread across partitions and handled in parallel, so shipped can overtake created (§05). Keying by something too coarse (country, tenant type) creates a hot partition; keying by something that changes (a session id) splits one entity's events across partitions.
Fix: key by the entity whose events must stay in order — usually its id — and check that kafka-go uses Murmur2Balancer if other clients write the same topic.
Infinite retries of a poison message
nack(msg, false, true) on every error, on a queue with no delivery limit, retries a message that can never succeed forever, at full speed (§04). In Kafka, retrying in place blocks the whole partition behind it.
Fix: a delivery limit (quorum queue x-delivery-limit, SQS maxReceiveCount, or a counter in your Kafka consumer), backoff between attempts, a DLQ, and an alert on its depth.
A visibility timeout shorter than the job
An SQS job takes 90 s; the visibility timeout is 30 s. At 30 s the message reappears and a second worker starts it; at 60 s a third. No crash anywhere, three copies of the work — and the first worker's delete at 90 s may not remove the message at all, because SQS expects the receipt handle from the most recent receive. The same happens in Kafka when one batch takes longer than max.poll.interval.ms (§06).
Fix: timeouts above your slowest normal job, extended while long jobs run (ChangeMessageVisibility), and smaller batches.
Treating the broker as infinite
“It's fine, it's in the queue.” Lag grows for days, retention deletes messages nobody read yet (Kafka), the broker runs out of disk or memory and stops accepting publishes (RabbitMQ's alarms block every publisher), and by then the backlog takes hours to clear (§06).
Fix: capacity with headroom, queue limits (§07), and alerts on lag in time and its trend (§11).
Scaling consumers past the partition count
The dashboard says lag is growing, so the autoscaler adds pods: 12 consumers on an 8-partition topic. Four sit idle, and every scaling step triggers a rebalance that pauses everyone (§06).
Fix: set the autoscaler's maximum to the partition count, choose partitions for peak parallelism up front, and use cooperative rebalancing or static membership.
Check yourself
Predict the answer before you click. Q1 is covered in §02 and §07, Q2 in §03, Q3 in §06, Q4 in §05, Q5 in §06, and Q6 in §10.
An order-paid event must reach both the email service and the analytics service. With RabbitMQ you point both services at the same queue. What happens?
for d := range msgs { // autoAck = false
d.Ack(false)
if err := charge(d.Body); err != nil {
log.Println(err)
}
}A Kafka topic has 4 partitions. Lag is growing, so you scale the consumer group from 4 to 10 instances. What happens?
You publish created, paid, shipped for order 42 to a 12-partition Kafka topic. Which choice keeps them in order for consumers?
Consumer lag is steady at 60,000 messages and the group processes 2,000 messages/s. Roughly how old is a message by the time it's processed?
Your Kafka producer has enable.idempotence=true. Your consumer charges a card, then commits its offset. It crashes between the two. On restart, what happens?
Cheat sheet
| I want to… | Use |
|---|---|
| Run background jobs, one worker per job | A queue (RabbitMQ, SQS) with manual acks and a DLQ |
| Let many services react to the same events | A log topic, one consumer group per service — or a RabbitMQ fanout/topic exchange with one queue per service |
| Rebuild a view or backfill a new service | A log with long enough retention (or a compacted topic), then replay from offset 0 |
| Keep one entity's events in order | Key by its id (Kafka), MessageGroupId (SQS FIFO), or a single active consumer (RabbitMQ) |
| Scale a Kafka consumer | Add consumers up to the partition count; beyond that, add partitions or speed up processing |
| Stop a poison message blocking everything | x-delivery-limit + dead-letter exchange, SQS redrive policy, or a DLQ topic in your Kafka consumer |
| Save to the DB and publish reliably | Transactional outbox (or CDC) |
| Not double-charge on redelivery | Idempotent consumer: dedup table, absolute updates, idempotency keys downstream |
| Know if consumers keep up | Consumer lag in time / age of oldest message, DLQ depth, redelivery rate |
| Shut down without losing work | Stop fetching, finish and ack what's in hand, close the consumer (leave the group), then exit |