Engineering Notes
Home/Distributed Systems/Message Brokers/Acknowledgements and Message Ordering
Distributed Systems · Message Brokers

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.

🕸️ Distributed Systems● Advanced⏱ 5 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

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.

BrokerAckNegative ackUnacked on crash
RabbitMQbasic.ackbasic.nack / basic.reject (requeue or dead-letter)redelivered (with redelivered=true)
SQSDeleteMessagelet the visibility timeout expire / ChangeMessageVisibility 0reappears after the visibility timeout
Kafkacommit offset (covers everything before it)no per-message nack; seek back or publish to a retry topicreprocessed 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 a consumer_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

CauseExample
competing consumersconsumer 1 gets Created, consumer 2 gets Cancelled, 2 is faster
requeue / retryCreated fails, is requeued to the back, Updated is processed first
parallel processing inside one consumerPromise.all over a batch from one partition
producer retries without idempotencebatch 2 succeeds, batch 1's retry lands after it
different keys for one entityOrderCreated 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)
"Set" update without versions
sql
UPDATE orders SET status = :status WHERE id = :id;
-- 'paid' (v2) arrives after 'shipped' (v3) → order shows 'paid'
Versioned, order-tolerant update
sql
UPDATE orders SET status = :status, version = :v
 WHERE id = :id AND version < :v;
-- the stale 'paid' (v2) updates 0 rows and is ignored

6Retries Without Blocking (Kafka)

Because Kafka acks are cumulative, a failing message at offset 105 blocks commits for 106+ in that partition. Options:

  1. retry in place a few times (blocks the partition — fine for brief glitches)
  2. 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
  3. 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.

Within one partition messages arrive in order but parallel processing completes them out of order; the consumer may only commit up to the lowest contiguous completed offset, while a per-key executor keeps each key serial
Within one partition messages arrive in order but parallel processing completes them out of order; the consumer may only commit up to the lowest contiguous completed offset, while a per-key executor keeps each key serial

⚠️ Common Pitfalls

  1. Acking before the work is durable → silent loss.
  2. Assuming the broker gives global order → bugs once you scale consumers.
  3. Requeue-on-failure in a FIFO queue → reorders messages for that entity and can loop forever on a poison message.
  4. Committing the highest finished offset with parallel processing → skipped records after a crash.
  5. Keying related events differently → they land on different partitions.
  6. No version checks in handlers that "set" state → older events overwrite newer state.

📋 Quick Reference

NeedTechnique
no lossprocess → durable → ack
per-entity orderkey by entity id; one in-flight message per key
scale with orderkeyed executor / FIFO message groups
tolerate reorderingversions, commutative updates
parallel + Kafka commitscommit the contiguous watermark
don't block a partitionretry 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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • What happened before idempotence? You had to set max.in.flight.requests.per.connection=1 to 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.

Follow-ups interviewers ask
  • 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.

1. Committing Kafka offset 105 acknowledges…
Commits are cumulative.
2. OrderCreated and OrderPaid for one order land on different partitions. Why might that be?
Key all of an entity's events by the entity ID.
3. Parallel processing within a partition: records 103 and 104 are done, 102 isn't. Safe commit?
Commit up to the lowest unfinished offset.
4. Which makes a handler tolerant of out-of-order events?
UPDATE … WHERE version < :v.