Skip to content

Exactly-Once Semantics (and the Myth)

“Exactly-once delivery” is the most requested and most misunderstood guarantee in distributed systems. Engineers ask for it; vendors advertise it; and at the level of message delivery over a network, it cannot exist. Understanding why — and what you can have instead — is a senior-level distinction that prevents a lot of broken designs.

At-most-once send, never retry → fast, but messages can be LOST
At-least-once send, retry until ack'd → never lost, but can DUPLICATE
Exactly-once each message acted on once, no loss, no dup → the dream

You’re always choosing between losing messages and duplicating them. Exactly-once promises to escape the dilemma — and that’s where the trouble starts.

The culprit is the unreliable network plus the two-generals reality: after sending a message, the sender either gets an acknowledgment or it doesn’t. If the ack doesn’t arrive, the sender cannot tell which of two worlds it’s in:

World A: message was delivered, but the ACK was lost → if I retry, that's a DUPLICATE
World B: message was never delivered → if I DON'T retry, that's a LOSS
The sender cannot distinguish A from B. Any fixed choice is wrong in one of them.

To never lose, you must retry on missing ack → which duplicates in World A. To never duplicate, you must not retry → which loses in World B. No protocol escapes this; it’s fundamental, the same wall the dual-write problem runs into.

The real solution: at-least-once + idempotency

Section titled “The real solution: at-least-once + idempotency”

Since you can’t prevent duplicates, make duplicates harmless. Choose at-least-once delivery (never lose), then design the consumer so that processing the same message twice has the same effect as processing it once — i.e. make it idempotent (see Idempotency).

at-least-once delivery (may duplicate) + idempotent processing (dedup) = effectively once

Two common ways to achieve idempotent processing:

  • Dedup by ID. Stamp each message with a unique ID; the consumer records processed IDs and skips any it has already seen.

    msg{id: 9f3, "credit $10"} → processed? no → apply, record 9f3
    msg{id: 9f3, "credit $10"} (duplicate) → processed? YES → skip
  • Naturally idempotent operations. Design the effect so repetition doesn’t matter: SET balance = 100 is idempotent; ADD 10 to balance is not. Upserts, “set to state X,” and conditional writes are your friends.

The dedup store has a cost — it must be durable and fast, and you must decide how long to remember IDs (a window long enough to outlast all retries). That’s the price of effectively-once.

  • Payment systems lean on this entirely: an idempotency key turns inevitable retries into a single charge (see Design a Payment System).
  • Message queues / stream processors (Kafka & friends) offer “exactly-once” features that are, under the hood, at-least-once + transactional dedup/offset commits — effectively-once within the system.
  • Webhooks are at-least-once by nature; any webhook consumer must dedup or it will double-process.

Under the hood — how Kafka’s “exactly-once” actually works

Section titled “Under the hood — how Kafka’s “exactly-once” actually works”

Kafka’s exactly-once is the clearest real example of “effectively-once = at-least-once + dedup,” and it has two distinct parts. First, the idempotent producer: each producer gets a producer ID, and every message carries a per-partition sequence number. When a network hiccup makes the producer retry a send, the broker sees a sequence number it has already written and silently drops the duplicate — so producer retries (the cause of duplicates) stop creating them at the log.

Second, transactions: a producer can write to several partitions and commit the consumer offsets it read from inside one atomic transaction, so a consume-process-produce loop either fully happens or fully doesn’t. Downstream consumers set isolation.level=read_committed to ignore records from transactions that never committed. Notice what’s not happening: no message crosses the network exactly once. Under the hood it’s still at-least-once delivery, with broker-side dedup and atomic offset commits doing the work — “effectively once,” exactly as this page describes, dressed in a production-grade implementation.

What does this buy us, and what does it cost? Chasing literal exactly-once delivery buys nothing — it’s a mirage. Accepting at-least-once and investing in idempotent processing + dedup buys real correctness (no loss, no duplicate effect) at the cost of a dedup store and the discipline of designing operations to be replay-safe. The senior move is to stop asking the network for a guarantee it can’t give and instead make your processing indifferent to duplicates.

  1. State the three delivery guarantees and the loss-vs-duplicate trade-off between them.
  2. Explain, using the lost-ack scenario, why exactly-once delivery is impossible.
  3. What do systems usually mean when they advertise “exactly-once”?
  4. How do at-least-once delivery and idempotent processing combine to give effectively-once?
  5. Why is SET balance = 100 idempotent while ADD 10 to balance is not, and why does that matter here?
Show answers
  1. At-most-once (send, never retry) is fast but messages can be lost; at-least-once (retry until ack’d) never loses but can duplicate; exactly-once (acted on once, no loss, no dup) is the dream. You’re always choosing between losing messages and duplicating them.
  2. After sending, if the ack doesn’t arrive the sender can’t tell World A (delivered, ack lost — retrying would duplicate) from World B (never delivered — not retrying would lose). To never lose you must retry → duplicates in A; to never duplicate you must not retry → loss in B. No protocol escapes this dilemma, so exactly-once delivery is impossible.
  3. They almost always mean effectively-once processing within their own boundary — at-least-once delivery combined with deduplication — not a message magically crossing the network exactly once. The guarantee lives in the processing, not the delivery.
  4. At-least-once guarantees no message is ever lost (you retry), and idempotent processing makes handling the same message twice have the same effect as once (so duplicates are harmless). Together they yield effectively-once: no loss, no duplicate effect.
  5. SET balance = 100 is idempotent — applying it twice leaves the same state — so a duplicate delivery is harmless. ADD 10 to balance is not — applying it twice adds 20 — so a duplicate corrupts the result. This matters because idempotent (or dedup-by-ID) operations are exactly what make at-least-once delivery safe; non-idempotent ones double-count on every retry.