Engineering Notes
Home/Distributed Systems/Message Brokers/Delivery Guarantees
Distributed Systems · Message Brokers

Delivery Guarantees

At-most-once, at-least-once or exactly-once: delivery guarantees are a property of the whole pipeline, decided by when each side acknowledges. Compare them failure by failure, and build 'exactly-once' the way real systems do: at-least-once plus idempotence.

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

🎯 What you'll learn

  • The three guarantees and the failures each accepts
  • Producer settings: acks, ISR and idempotence
  • Consumer ack order and its consequences
  • Dedup tables, Kafka transactions, and choosing per data type

1The Three Guarantees

Every message passes through failure-prone steps: producer → broker, broker storage, broker → consumer, consumer processing. A delivery guarantee says how many times a message's effect can happen when any of those steps fails.

GuaranteeMeaningFailure mode you accept
at-most-once0 or 1 timesmessages can be lost
at-least-once1 or more timesmessages can be duplicated
exactly-onceexactly 1 time(requires cooperation from every component)

They are not broker features you switch on; they're properties of the whole pipeline, decided by when each side acknowledges.

2Producer Side

SettingLoss on broker failure?Duplicates on retry?
fire-and-forget (acks=0, no confirms)yesno
wait for leader (acks=1)yes, if the leader dies before followers copy ityes, if the ack is lost and you retry
wait for all in-sync replicas (acks=all + min.insync.replicas=2)no (as long as one ISR replica survives)yes, unless idempotent
acks=all + idempotent producernono (within the producer session)

RabbitMQ's equivalent is publisher confirms + durable queues + persistent messages (+ quorum queues for replication).

3Consumer Side

text
at-most-once:   receive → ACK → process        crash after ack  → message lost
at-least-once:  receive → process → ACK        crash before ack → message redelivered

At-least-once is the practical default: losing data is usually worse than processing it twice, and duplicates can be neutralized.

4"Exactly-Once" in Practice = At-Least-Once + Idempotence

True exactly-once delivery across a network is impossible in general (the Two Generals problem: you can never be sure the other side received your last message). What systems actually provide is exactly-once processing / effect:

  1. Idempotent consumers — processing the same message twice has the same effect as once:
    • dedup table: INSERT INTO processed_messages(message_id) ... in the same DB transaction as the side effect; a duplicate hits the unique constraint and is skipped
    • natural idempotency: "set status = SHIPPED", upserts keyed by event id
    • downstream idempotency keys (Idempotency-Key header to a payment API)
  2. Transactional read-process-write (Kafka EOS) — a Kafka Streams / transactional producer writes output records and the consumer's offset commit in one Kafka transaction; consumers reading with isolation.level=read_committed never see aborted output. This gives exactly-once for Kafka → Kafka pipelines only — any external side effect (an email, an HTTP call, a non-Kafka DB) is outside the transaction.
  3. Store offsets with results — write the processing result and the consumed offset in one database transaction; on restart, seek to the stored offset.
💡 Example: a dedup table in the same transaction
sql
BEGIN;
  INSERT INTO processed_messages (message_id) VALUES ('evt-81f…');
  -- unique violation here → duplicate → ROLLBACK and ack
  UPDATE accounts SET points = points + 50 WHERE id = 'c-4';
COMMIT;
-- then commit the offset / ack the message

The side effect and the "seen" marker commit atomically, so a redelivery can never apply the points twice.

5Picking a Guarantee

DataGuaranteeWhy
metrics, telemetry, logsat-most-once (or at-least-once)a lost data point is fine; throughput matters
emails, notificationsat-least-once + dedupa missing email is worse than a rare duplicate; dedup makes duplicates rare
payments, orders, ledger entriesat-least-once + strict idempotencyduplicates = money; loss = money
stream aggregation in KafkaKafka EOSbuilt-in transactional semantics
Under the HoodHow Kafka Transactions Commit Atomically▾

A transactional producer registers a transactional.id with a transaction coordinator (a broker owning a partition of the internal __transaction_state log). Then:

  1. beginTransaction() — local state only.
  2. Each send() to a new partition first registers that partition with the coordinator (AddPartitionsToTxn), then writes records normally — they're in the log, but marked as part of an open transaction.
  3. sendOffsetsToTransaction() registers the consumer group's offsets as part of the same transaction.
  4. commitTransaction() — the coordinator writes PREPARE_COMMIT to its log (the point of no return), then writes a commit marker into every involved partition (including __consumer_offsets), then COMPLETE_COMMIT.

read_committed consumers only read up to the last stable offset — the first offset of any still-open transaction — and skip records of aborted transactions. A new producer instance with the same transactional.id bumps an epoch, fencing off a zombie predecessor so it can't commit.

At-most-once acks before processing and loses messages on a crash, at-least-once processes before acking and redelivers duplicates, and effectively-once adds an idempotent dedup check inside the same transaction as the side effect
At-most-once acks before processing and loses messages on a crash, at-least-once processes before acking and redelivers duplicates, and effectively-once adds an idempotent dedup check inside the same transaction as the side effect

⚠️ Common Pitfalls

  1. Believing a broker setting gives exactly-once end-to-end — external side effects are never covered.
  2. acks=1 for critical data — leader failure loses acknowledged writes.
  3. Dedup check outside the transaction of the side effect — a crash between them still duplicates or loses.
  4. Dedup on payload hashes — two genuinely identical, separate events get merged. Use explicit event IDs.
  5. Unbounded dedup tables — expire entries after the maximum redelivery window.
  6. Auto-commit + async processing — offsets committed for messages still being processed → loss.

📋 Quick Reference

text
at-most-once  = ack → process
at-least-once = process → ack                         (default)
effectively-once = at-least-once + idempotent handler (dedup id in the same txn)
Kafka EOS     = transactional producer + sendOffsetsToTransaction + read_committed  (Kafka → Kafka only)
durable produce = acks=all + min.insync.replicas ≥ 2 + idempotence

🧠 Key takeaways

  • At-most-once may lose, at-least-once may duplicate; true exactly-once delivery is impossible over a network.
  • Producer side: acks=all + min.insync.replicas + idempotent producer (or publisher confirms + quorum queues).
  • Consumer side: process then ack is the practical default.
  • "Exactly-once" = at-least-once + idempotent processing (dedup in the same transaction), or Kafka transactions for Kafka-to-Kafka.

🎤 Interview Q&A

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

Q1Explain at-most-once, at-least-once and exactly-once.core▾

At-most-once: messages may be lost but never duplicated (ack before processing). At-least-once: never lost but may be duplicated (ack after processing). Exactly-once: each message affects state once; achievable within a closed system (Kafka transactions) or via at-least-once + idempotency end to end.

Follow-ups interviewers ask
  • Why is true exactly-once delivery impossible over a network? Because an ack can be lost, so the sender can't know whether to resend (Two Generals problem).
Q2How do you achieve effectively-once processing?core▾

At-least-once delivery plus idempotent consumers: dedupe by message ID in a store updated in the same transaction as the side effect, or use naturally idempotent operations and conditional writes.

Follow-ups interviewers ask
  • What if the side effect is an external API call? Pass an idempotency key to that API.
Q3Where can messages be lost in a pipeline?core▾

Producer side (fire-and-forget, crash before send, dual write without outbox), broker side (non-durable queues, acks=1 with leader failure), consumer side (auto-ack before processing, committing offsets early).

Follow-ups interviewers ask
  • How do you find losses? End-to-end counts and reconciliation between source and sink.
Q4What's the transactional outbox and how does it relate to delivery guarantees?senior▾

It guarantees a message is published if and only if the business transaction committed, by storing the message in the same DB transaction and relaying it afterwards. The relay gives at-least-once publication.

Follow-ups interviewers ask
  • What's the inbox pattern? The consumer-side mirror: record received message IDs to dedupe.
Q5What does at-least-once mean for system design?core▾

Every consumer and downstream API must tolerate duplicates; counters and balances need dedupe; emails and notifications need idempotency keys; and tests should simulate redelivery.

Follow-ups interviewers ask
  • Common bug? Incrementing a counter on each delivery, so duplicates inflate it.

🛠️ 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. Which guarantee accepts duplicates but never loses messages?
Neutralize duplicates with idempotency.
2. Why must the dedup insert be in the same transaction as the side effect?
Atomicity closes the window.
3. Metrics telemetry where a lost sample is fine and throughput matters most:
Pick the guarantee per data type.
4. acks=1 can lose data when…
Use acks=all with min.insync.replicas.