Skip to content

Exactly-Once Delivery: Myth vs Reality

CoreConceptJuly 21, 20266 min read

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.

At-least-once delivery + idempotent consumer = exactly-once effect
At-least-once delivery + idempotent consumer = exactly-once effect

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.

Two Generals: no finite ack chain gives both sides certainty
Two Generals: no finite ack chain gives both sides certainty

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).

Idempotent consumer: dedup key and side effect commit together
Idempotent consumer: dedup key and side effect commit together

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.
Non-idempotent — redelivery double-charges
1async function onInvoiceApproved(event: InvoiceApprovedEvent) {2  await paymentProcessor.charge(event.customerId, event.amountCents);3  await ack(event); // if this line never runs, the broker redelivers4}
Idempotent — dedup key checked in the same transaction
1async function onInvoiceApproved(event: InvoiceApprovedEvent) {2  const already = await db.processedEvents.exists(event.id);3  if (already) return ack(event); // second delivery — no-op, still ack it4 5  await db.transaction(async (tx) => {6    await tx.processedEvents.insert({ id: event.id, processedAt: new Date() });7    await paymentProcessor.charge(event.customerId, event.amountCents, {8      idempotencyKey: event.id, // processor also dedupes on its side9    });10  });11  await ack(event);12}13// Verify: replay event.id ten times → exactly one charge.

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.

Kafka EOS covers the pipeline — not any external call a consumer makes
Kafka EOS covers the pipeline — not any external call a consumer makes

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.

invoice-4471: consumer crash between external charge and offset commit
invoice-4471: consumer crash between external charge and offset commit

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.
External call outside any transactional boundary
1// No idempotency key — the processor cannot tell these two calls apart2await paymentProcessor.charge(customerId, amountCents);
Idempotency key ties both delivery attempts to one charge
1await paymentProcessor.charge(customerId, amountCents, {2  idempotencyKey: event.id, // same key on redelivery — processor dedupes3});4// Verify: two calls with the same idempotencyKey → processor's dashboard5// shows one successful charge and one deduplicated request.

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.

Share:

Related Articles

A consumer service scales from two instances to eight expecting throughput to climb accordingly — instead, six of the ei

Read

Asynchronous messaging decouples services in time — producers send without waiting for consumers. Kafka is a distributed

Read

A signup service inserts a new User row, then calls the broker to publish UserRegistered so the welcome-email and loyalt

Read

Keep learning

Follow a structured path or browse all courses to go deeper.