Acknowledgements and Message Ordering
Acks decide when a message counts as done; ordering is only guaranteed within one queue or partition. See exactly how competing consumers, requeues and parallel processing reorder events, and the techniques that keep per-entity order while scaling.
🎯 What you'll learn
- Individual vs cumulative acknowledgements
- When to ack, and lease extension for long work
- Why ordering guarantees are narrow and how they break
- Keyed executors, version checks, retry topics and commit watermarks
📚 Before you start
1What an Acknowledgement Means
An ack is the consumer telling the broker "I'm done with this message — don't give it to anyone again." Until a message is acked, the broker considers it in flight and will redeliver it if the consumer disconnects or the visibility timeout expires.
| Broker | Ack | Negative ack | Unacked on crash |
|---|---|---|---|
| RabbitMQ | basic.ack | basic.nack / basic.reject (requeue or dead-letter) | redelivered (with redelivered=true) |
| SQS | DeleteMessage | let the visibility timeout expire / ChangeMessageVisibility 0 | reappears after the visibility timeout |
| Kafka | commit offset (covers everything before it) | no per-message nack; seek back or publish to a retry topic | reprocessed from the last commit |
Kafka's ack is cumulative: committing offset 105 acknowledges 0–104. RabbitMQ and SQS acks are individual. That difference shapes how you handle failures and ordering.
2When to Ack
- After the side effect is durably done (DB committed, email provider accepted) — at-least-once.
- Never "ack early to free the broker" for messages you can't afford to lose.
- Long-running work: extend the lease (SQS
ChangeMessageVisibility, RabbitMQ has no timeout by default but has aconsumer_timeout), or the message is redelivered while you're still working on it.
3Ordering Guarantees Are Narrow
Brokers only guarantee ordering within a single queue or partition, and only for a single consumer processing sequentially:
- Kafka: ordered within a partition. Use the entity id as the key (
orderId) so all of one entity's events share a partition. - RabbitMQ: FIFO per queue — but with multiple consumers or prefetch > 1 with requeues, processing order breaks.
- SQS standard: best-effort ordering only. SQS FIFO: ordered per
MessageGroupId.
You almost never need global order. You need order per entity ("OrderCancelled must not be applied before OrderCreated for the same order").
4How Ordering Breaks in Practice
| Cause | Example |
|---|---|
| competing consumers | consumer 1 gets Created, consumer 2 gets Cancelled, 2 is faster |
| requeue / retry | Created fails, is requeued to the back, Updated is processed first |
| parallel processing inside one consumer | Promise.all over a batch from one partition |
| producer retries without idempotence | batch 2 succeeds, batch 1's retry lands after it |
| different keys for one entity | OrderCreated keyed by orderId, OrderPaid keyed by paymentId → different partitions |
5Keeping Order While Scaling
- Partition by key, process each key serially: inside one consumer you can run many keys in parallel as long as each key's messages are processed one at a time (a per-key queue / "keyed executor").
- SQS FIFO message groups / RabbitMQ consistent-hash exchange: the same idea in other brokers.
- Make handlers order-tolerant where possible:
- carry a version / sequence number in each event; ignore events older than what you've stored (
UPDATE ... WHERE version < :v) - use commutative updates (increment counters, add to sets) rather than "set to"
- buffer out-of-order events briefly until the gap is filled (with a timeout)
- carry a version / sequence number in each event; ignore events older than what you've stored (
"Set" update without versions
UPDATE orders SET status = :status WHERE id = :id;
-- 'paid' (v2) arrives after 'shipped' (v3) → order shows 'paid'Versioned, order-tolerant update
UPDATE orders SET status = :status, version = :v
WHERE id = :id AND version < :v;
-- the stale 'paid' (v2) updates 0 rows and is ignored6Retries Without Blocking (Kafka)
Because Kafka acks are cumulative, a failing message at offset 105 blocks commits for 106+ in that partition. Options:
- retry in place a few times (blocks the partition — fine for brief glitches)
- publish the failed message to a retry topic (
orders.retry.1m,orders.retry.10m) and commit — unblocks the partition but gives up strict per-key ordering for that key unless you also park subsequent messages of the same key - after N attempts, dead-letter it (
10-dead_letter_queues/)
Under the HoodWhy Cumulative Acks Make Kafka Fast — and Rigid▾
A Kafka consumer's entire acknowledgement state is one integer per partition. The broker doesn't track which individual records were processed; it doesn't need per-message locks, redelivery flags or timers. That's a big part of why Kafka can serve millions of messages per second with tiny broker overhead.
The rigidity follows directly: you can't say "105 failed but 106–110 succeeded" — committing 111 acknowledges 105 too. If you process records in parallel and commit, you must commit only up to the lowest contiguous completed offset (a "commit watermark"), or a crash can skip unfinished records. RabbitMQ and SQS track each delivery individually (a delivery tag / receipt handle per message), which allows fine-grained nack/requeue and per-message timeouts — at the cost of more broker-side state per message.
⚠️ Common Pitfalls
- Acking before the work is durable → silent loss.
- Assuming the broker gives global order → bugs once you scale consumers.
- Requeue-on-failure in a FIFO queue → reorders messages for that entity and can loop forever on a poison message.
- Committing the highest finished offset with parallel processing → skipped records after a crash.
- Keying related events differently → they land on different partitions.
- No version checks in handlers that "set" state → older events overwrite newer state.
📋 Quick Reference
| Need | Technique |
|---|---|
| no loss | process → durable → ack |
| per-entity order | key by entity id; one in-flight message per key |
| scale with order | keyed executor / FIFO message groups |
| tolerate reordering | versions, commutative updates |
| parallel + Kafka commits | commit the contiguous watermark |
| don't block a partition | retry topics + DLQ (accept ordering trade-off) |
🧠 Key takeaways
- RabbitMQ/SQS acks are individual; Kafka commits are cumulative (offset N acks everything before it).
- Ack only after the side effect is durable; extend leases for long work.
- Order is guaranteed only per queue/partition and breaks with competing consumers, requeues, parallelism and mismatched keys.
- Keep order per entity with keys + keyed executors, or tolerate reordering with versions and commutative updates; commit the contiguous watermark.
🎤 Interview Q&A
Q1What's the difference between auto-ack and manual ack?core▾
Auto-ack removes a message as soon as it's delivered, so a consumer crash loses it (at-most-once). Manual ack removes it only after the consumer confirms processing (at-least-once), at the cost of possible redelivery.
- Which do you use? Manual ack for anything important.
Q2Why can messages arrive out of order even if the broker preserves order?core▾
Multiple competing consumers process in parallel, retries/requeues put failed messages back later, producers retrying can reorder sends, and different partitions have no relative order.
- Fix for per-entity order? Key messages by entity and process each key sequentially.
Q3How do you handle out-of-order events in consumers?core▾
Include a version or sequence number per entity and ignore stale updates (only apply if version > stored version), or buffer briefly and reorder, or design commutative operations that don't depend on order.
- Example? Apply 'status=shipped v5' only if the stored version is below 5.
Q4How does Kafka preserve ordering with retries on the producer?senior▾
With idempotent producers (enable.idempotence=true), the broker uses sequence numbers to reject duplicates and keep order even with up to 5 in-flight requests per connection.
- What happened before idempotence? You had to set
max.in.flight.requests.per.connection=1to keep order with retries.
Q5What's a poison message and how does it affect ordering?core▾
A message that always fails processing. In an ordered partition it blocks everything behind it. You must decide: retry with limits, then park it in a DLQ (breaking strict order for that key) or halt and alert if order is critical.
- How do you park while keeping order per key? Park subsequent messages for the same key too, until the poison one is resolved.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.