Tier 2 · Distributed Core

Idempotency & the Exactly-Once Myth

Exactly-once delivery is impossible; exactly-once effect is a design choice

⏱ 18 min distributedcorrectnessmessaging

The three delivery semantics

At-most-once. Send and don't retry. Never duplicates, sometimes loses. Acceptable for metrics samples, log lines, telemetry — anything where one missing point is invisible.

At-least-once. Retry until acknowledged. Never loses, sometimes duplicates. The default for anything that matters.

Exactly-once. Each message is processed precisely once.

And the fact that everything else follows from:

The practical consequence: stop trying to make delivery exact, and make processing insensitive to duplicates.

Natural idempotency

An operation is idempotent if applying it repeatedly has the same effect as applying it once.

Some operations are naturally idempotent, and you can often choose the idempotent form:

Not idempotent Idempotent equivalent
balance = balance - 10 balance = 90 (absolute), or guarded by a transfer ID
INSERT INTO orders … INSERT … ON CONFLICT (order_id) DO NOTHING
counter++ Record the event, count distinct events
POST /orders PUT /orders/{client-generated-id}
"Send the email" "Ensure an email for event X has been sent"
list.append(x) set.add(x)

The pattern: state the desired end state rather than the delta, or attach an identity to the operation so repeats are recognisable. HTTP encodes this distinction directly — PUT and DELETE are defined as idempotent, POST is not, which is why "create with a client-supplied ID via PUT" is such a common API design.

Idempotency keys

When the operation can't be naturally idempotent, make it so explicitly. This is the Stripe model and it's the standard answer:

  1. The client generates a unique key per logical operation (a UUID) and sends it as a header. Crucially, the key is generated once, before the first attempt, and reused on every retry — a key generated per HTTP call defeats the whole mechanism.
  2. The server, in a transaction, attempts to insert the key into an idempotency_keys table with a unique constraint.
  3. If the insert succeeds, this is the first attempt: do the work, store the response against the key, return it.
  4. If the insert fails on the unique constraint, this is a retry: return the stored response without re-executing.

Details that matter in practice:

The transactional outbox

Related and equally important: how do you atomically update the database and publish an event?

Dual writes fail — as choosing a datastore covered, a crash between the two leaves them inconsistent forever.

The outbox pattern:

  1. In the same transaction as the business write, insert a row into an outbox table.
  2. A separate process reads unpublished outbox rows and publishes them, marking them sent.

Atomicity is free, because it's one transaction in one database. Publishing is at-least-once, because the publisher can crash after sending and before marking — so consumers must be idempotent, which is the theme of this lesson.

The read side of the same idea is change data capture: read the database's replication log directly and derive events from it. Same guarantee, no application-level outbox table.

Effectively-once in stream processing

Kafka's "exactly-once semantics" is worth understanding precisely, since it's frequently misdescribed.

It works for the consume → process → produce pattern, by making the output writes and the consumer offset commit part of one atomic transaction. If processing fails, neither the output nor the offset advance, so the retry produces the same result — and downstream consumers reading with read_committed never see the aborted attempt.

What it does not do is make an external side effect exactly-once. If your processor charges a credit card or sends an email, Kafka transactions cannot roll that back. Exactly-once applies within the transactional boundary; everything outside it still needs an idempotency key.

Deduplication, when you can't be idempotent

Sometimes you need to detect duplicates rather than tolerate them:

Every one of these has a window. A duplicate arriving after the window expires will be processed again. Size the window from your maximum realistic retry delay, and be explicit that it's a window rather than a guarantee.

What to take away

Check yourself

  1. Why is exactly-once delivery impossible in a distributed system?

  2. A client generates a fresh idempotency key on every HTTP attempt, including retries. What happens?

  3. You need to update the database and publish an event atomically. What is the correct approach?

  4. Your consumer checks whether an order was already processed, then charges a card, then records the result. It crashes after charging but before recording. What happens on retry?