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.
The three delivery guarantees
Section titled “The three delivery guarantees”At-most-once send, never retry → fast, but messages can be LOSTAt-least-once send, retry until ack'd → never lost, but can DUPLICATEExactly-once each message acted on once, no loss, no dup → the dreamYou’re always choosing between losing messages and duplicating them. Exactly-once promises to escape the dilemma — and that’s where the trouble starts.
Why exactly-once delivery is impossible
Section titled “Why exactly-once delivery is impossible”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 DUPLICATEWorld 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 onceTwo 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 9f3msg{id: 9f3, "credit $10"} (duplicate) → processed? YES → skip -
Naturally idempotent operations. Design the effect so repetition doesn’t matter:
SET balance = 100is idempotent;ADD 10 to balanceis 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.
Where this bites in practice
Section titled “Where this bites in practice”- 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.
The thread
Section titled “The thread”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.
Check your understanding
Section titled “Check your understanding”- State the three delivery guarantees and the loss-vs-duplicate trade-off between them.
- Explain, using the lost-ack scenario, why exactly-once delivery is impossible.
- What do systems usually mean when they advertise “exactly-once”?
- How do at-least-once delivery and idempotent processing combine to give effectively-once?
- Why is
SET balance = 100idempotent whileADD 10 to balanceis not, and why does that matter here?