Exactly-Once Delivery: Myth vs Reality
An invoicing consumer reads InvoiceApproved from a queue, calls the payment processor to charge the card, and commits its offset. The broker redelivers the message — maybe the consumer crashed right after the charge but before the commit — and the customer is charged twice. The team's postmortem says "we need exactly-once delivery." That request is asking for something that provably cannot exist over an unreliable network.
This guide grounds that claim in the Two Generals Problem, separates what "exactly-once" actually means in systems like Kafka (exactly-once within the pipeline) from what teams usually want (no duplicate external side effects), and traces one invoice charge that gets duplicated anyway — because the external payment call sits outside any guarantee Kafka can make. For the delivery guarantees behind async processing generally, see Synchronous vs Asynchronous Processing; for the outbox side of this problem, see Transactional Outbox Pattern.
Why Exactly-Once Is Hard: The Two Generals Problem
Two generals command armies on separate hills and must attack the enemy in the valley at the same time, or lose. They can only communicate by sending a messenger through enemy territory, where the messenger might be captured. General A sends "attack at dawn." To be sure General B got it, A needs an acknowledgment. But B can't be sure A received that acknowledgment either — so B would need an ack for the ack, forever. No finite number of messages lets both generals be simultaneously certain the other will attack. This isn't a failure of cleverness; it's a proof that no protocol over an unreliable channel can guarantee both sides have common knowledge of a single event.
Map this onto message delivery: a producer sends a message and wants to know the consumer processed it exactly once. The consumer's acknowledgment can be lost exactly like the messenger — so the producer must choose between two failure modes: retry without a confirmed ack (risking a duplicate if the first message actually arrived) or don't retry (risking the message never arrives at all). Every real messaging system picks the first: at-least-once, with duplicates as the accepted cost of never silently losing a message.
Quick reference
- The Two Generals Problem is a proof, not an engineering gap — no amount of retries or acks closes it over an unreliable network.
- Every real broker (Kafka, RabbitMQ, SQS) defaults to at-least-once for exactly this reason: losing a message silently is worse than occasionally duplicating one.
- At-most-once (send once, never retry) is the other option — it trades duplicates for silent loss, rarely what teams actually want.
- This is the same impossibility that makes 2PC's coordinator a single point of blocking — see Saga vs Two-Phase Commit for the transactional-lock version of the same trade-off.
- "Exactly-once delivery" as a literal claim about an unreliable network is not achievable; what's achievable is exactly-once effect, built on top of at-least-once delivery.
Remember this
The Two Generals Problem proves no ack-based protocol can guarantee both sides know a message was received exactly once — every real broker accepts duplicates as the price of never silently losing a message.
At-Least-Once Plus Idempotency Equals Effectively-Once
Since exactly-once delivery is off the table, the practical goal is exactly-once effect: a message can arrive twice, but processing it twice produces the same result as processing it once. The mechanism is a processed-message record, checked and written in the same transaction as the side effect itself — not as a separate best-effort step.
This is the same idempotent-consumer pattern that makes an outbox relay's at-least-once delivery safe. The dedup key should be something the message actually carries — an event id, an idempotency key from the original request — not something derived from content that could coincidentally repeat (like a customer id alone, which would wrongly block a second legitimate charge for the same customer).
Quick reference
- Dedup key must come from the message itself (event id, idempotency key) — never derived from content alone.
- Record the processed-id and perform the side effect in the same transaction, or a crash between them reopens the exact gap this pattern closes.
- Passing the same event id as an idempotency key to an external payment API gets you dedup on both sides of the call.
- Idempotent does not mean 'safe to ignore ordering' — a duplicate handled out of order can still corrupt state that depends on sequence.
- This is the effectively-once pattern every 'exactly-once' broker feature is actually built to support, not replace.
Remember this
Effectively-once is at-least-once delivery plus a dedup check performed in the same transaction as the side effect — not a broker feature, a consumer-side discipline.
What Kafka's Exactly-Once Semantics Actually Covers
Kafka's exactly-once semantics (EOS) are real, not marketing — but they cover a specific, bounded scope: the path from producing a message, through the topic, to a consumer that reads, processes, and produces further messages entirely within Kafka. An idempotent producer attaches a producer ID and sequence number to each message so the broker can reject a retried duplicate at the point of write. Kafka transactions let a consume-transform-produce step commit its output messages and its consumer-offset update atomically — either both happen or neither does, so a crash mid-step can't half-apply the transform.
What EOS does not cover is any side effect outside Kafka's own transactional boundary. Calling a payment API, writing to an external database, or sending an email are not part of the Kafka transaction — Kafka has no way to make an external HTTP call roll back. EOS guarantees the Kafka-internal bookkeeping is exactly-once; it says nothing about the external side effect a consumer triggers while processing a message.
Quick reference
- Idempotent producer: producer ID + per-partition sequence number lets the broker discard a retried duplicate write.
- Kafka transactions: consumer offset commit and produced output messages commit together — atomic within Kafka.
- Scope: Kafka-to-Kafka pipelines (read topic A, transform, write topic B) get genuine exactly-once processing.
- Out of scope: any external call — payment APIs, external databases, emails — made during processing is not covered by the transaction.
- "We use Kafka so we don't need idempotency" is the myth this section corrects — EOS and consumer-side idempotency solve different parts of the problem.
Remember this
Kafka's exactly-once semantics genuinely deliver exactly-once processing within Kafka's own transactional boundary — but stop at the edge of any external side effect a consumer triggers.
Trace One Duplicate Charge to Its Root Cause
Trigger. Consumer reads InvoiceApproved for invoice-4471 from a Kafka topic, calls the external payment processor, which charges the card and returns success. Before the consumer commits its offset, the process crashes. Symptom. Kafka redelivers invoice-4471 to the next consumer instance, which has no record the charge already happened, and calls the payment processor again — the customer is now charged twice.
Root mechanism. The offset commit is inside Kafka's transactional boundary; the payment API call is not. Kafka's EOS guarantees are working exactly as designed — no message was lost, and Kafka's own bookkeeping is consistent — the duplicate happened entirely outside anything Kafka's transaction could cover. Recovery: the payment processor's own idempotency-key support (most major processors accept one) rejects the second charge attempt as a duplicate of the first, provided the consumer passed the same key both times. Verification: query the payment processor for invoice-4471's idempotency key and confirm exactly one successful charge recorded, despite two API calls. Prevention: always pass a stable, message-derived idempotency key to any external call made during message processing — the fix lives at the consumer and the external API, not inside Kafka.
Quick reference
- Trigger: consumer crashes between an external side effect and its offset commit.
- Symptom: Kafka redelivers a message whose external effect already happened once.
- Root mechanism: the external call is outside Kafka's transactional boundary — EOS never covered it.
- Recovery: the external system's own idempotency-key support catches the duplicate, not Kafka.
- Prevention: pass a stable, message-derived key to every external call a consumer makes — this is the actual fix, every time.
Remember this
A duplicate external charge after a Kafka EOS pipeline isn't a Kafka bug — it's proof that EOS's boundary stops exactly where the external side effect starts.
Key takeaway
Exactly-once message delivery over an unreliable network is provably impossible — the Two Generals Problem shows why. What's achievable, and what every real system actually ships, is at-least-once delivery plus an idempotent consumer, producing exactly-once effect. Kafka's exactly-once semantics genuinely deliver that guarantee inside its own transactional boundary; any external side effect a consumer triggers needs its own dedup key, because Kafka's guarantee stops at the edge of the pipeline.
Practice (25 min): build a consumer for InvoiceApproved that calls a fake payment API and commits its offset afterward. Kill the consumer after the fake charge succeeds but before the offset commit, and confirm redelivery calls the payment API again. Add an idempotency key derived from the event id, verify the fake payment API rejects the second call as a duplicate, and confirm exactly one charge is recorded. Pass when ten redeliveries of the same event produce exactly one charge and ten acknowledged offsets.
Related Articles
Explore this topic