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.
🎯 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
📚 Before you start
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.
| Guarantee | Meaning | Failure mode you accept |
|---|---|---|
| at-most-once | 0 or 1 times | messages can be lost |
| at-least-once | 1 or more times | messages can be duplicated |
| exactly-once | exactly 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
| Setting | Loss on broker failure? | Duplicates on retry? |
|---|---|---|
fire-and-forget (acks=0, no confirms) | yes | no |
wait for leader (acks=1) | yes, if the leader dies before followers copy it | yes, 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 producer | no | no (within the producer session) |
RabbitMQ's equivalent is publisher confirms + durable queues + persistent messages (+ quorum queues for replication).
3Consumer Side
at-most-once: receive → ACK → process crash after ack → message lost
at-least-once: receive → process → ACK crash before ack → message redeliveredAt-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:
- 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-Keyheader to a payment API)
- dedup table:
- 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_committednever 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. - Store offsets with results — write the processing result and the consumed offset in one database transaction; on restart, seek to the stored offset.
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 messageThe side effect and the "seen" marker commit atomically, so a redelivery can never apply the points twice.
5Picking a Guarantee
| Data | Guarantee | Why |
|---|---|---|
| metrics, telemetry, logs | at-most-once (or at-least-once) | a lost data point is fine; throughput matters |
| emails, notifications | at-least-once + dedup | a missing email is worse than a rare duplicate; dedup makes duplicates rare |
| payments, orders, ledger entries | at-least-once + strict idempotency | duplicates = money; loss = money |
| stream aggregation in Kafka | Kafka EOS | built-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:
beginTransaction()— local state only.- 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. sendOffsetsToTransaction()registers the consumer group's offsets as part of the same transaction.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.
⚠️ Common Pitfalls
- Believing a broker setting gives exactly-once end-to-end — external side effects are never covered.
acks=1for critical data — leader failure loses acknowledged writes.- Dedup check outside the transaction of the side effect — a crash between them still duplicates or loses.
- Dedup on payload hashes — two genuinely identical, separate events get merged. Use explicit event IDs.
- Unbounded dedup tables — expire entries after the maximum redelivery window.
- Auto-commit + async processing — offsets committed for messages still being processed → loss.
📋 Quick Reference
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
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.
- 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.
- 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).
- 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.
- 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.
- 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.