Module P-16·36 min read

Idempotency keys done properly, why exactly-once delivery is an illusion, and how wide a dedupe window has to be to be worth anything.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-16 — Idempotency and Deduplication

What this module covers: Why every retry mechanism you have already built obliges you to build this one — the ambiguous failure that makes duplicates structural rather than exceptional. What idempotency actually means (identical effect, not identical response), and the difference between making an operation naturally idempotent and remembering that you already did it. Idempotency keys done properly: client-supplied, scoped, fingerprinted, and inserted as a uniqueness constraint so concurrent duplicates serialise instead of both executing. The atomicity problem when the side effect lives outside your database. Why exactly-once delivery is provably unavailable and exactly-once effect is not. And how wide a dedupe window has to be before it is worth anything at all.


Every Retry You Have Already Chosen Is a Duplicate You Have Already Ordered

You did not decide to have duplicates. You decided to have retries, which is the same decision viewed from the other end.

Count the places in a system built through this course so far that will re-send something. The load balancer retries a request to a second backend when the first connection resets (Module F-11). The HTTP client retries on a 503 with backoff (Module A-6). The queue redelivers after a visibility timeout expires (Module P-14). The Kafka consumer group re-reads from the last committed offset after a rebalance (Module P-15). The webhook sender retries nine times over two days (Module P-17). The background job runner retries a failed job (Module P-19). The user, seeing a spinner, presses the button again — and the user is the retry mechanism with the fewest safeguards and the shortest patience.

Every one of those is correct engineering. Removing them costs you availability. Keeping them means duplicates arrive, and the arithmetic is not favourable: a system with six independent retry mechanisms does not have a small chance of duplicates, it has a routine and continuous supply of them.

The root cause underneath all six is one thing, and it is worth naming precisely because it never goes away:

A caller that does not receive a response has learned nothing about whether the work happened.

text

This is the ambiguous failure, and it is the single most important shape in distributed systems after the partition (Module P-10). A timeout is not a failure. A connection reset is not a failure. A 502 from a proxy is not a failure. Each is an absence of information, and the caller has exactly two options: give up, and risk having lost work that the user believes happened; or retry, and risk doing it twice.

Almost every system chooses retry, because "the payment silently didn't happen" is worse than "the payment happened twice and we can detect it." That choice is at-least-once delivery, and this module is the bill for it.

Analogy: you post a letter containing a cheque and the recipient's confirmation letter is lost in the return post. You cannot tell whether your cheque arrived. If you post a second cheque and the first did arrive, you have paid twice. The fix is not better postage — it is writing an invoice number on the cheque, so the recipient's bookkeeper recognises the second one as a copy of a payment already recorded rather than as a second payment due. The number does the work, not the delivery guarantee.

Why this matters in production: the duplicate does not announce itself. Nothing errors. Your dashboards are green, the queue is drained, latency is normal, and two rows exist where one should. You find out from a customer, from a reconciliation report if you built one, or from a support ticket about being billed twice — typically days later, at which point the remediation is a manual refund and an apology per affected account.


Idempotent Means the Effect Repeats to Nothing, Not That the Response Is Identical

The textbook definition — "an operation that can be applied multiple times without changing the result beyond the initial application" — is correct and slightly misleading, because engineers read "result" as "response body" and it means "state of the system."

Sort the operations you have:

OperationIdempotent?Why
GET /orders/42Yesreads nothing changed
PUT /orders/42 {status:"shipped"}Yesassignment; applying twice sets the same value
DELETE /orders/42Yes, in effectsecond call finds nothing to delete; state is identical
POST /ordersNocreation; twice makes two
UPDATE balance SET amount = amount + 40Noread-modify-write on a relative value
INCR viewsNosame shape, one command
SET status = 'shipped'Yesabsolute assignment
INSERT ... ON CONFLICT DO NOTHINGYesconditional creation
Send emailNo, and unfixablethe effect left your system

The pattern separating the two columns is absolute versus relative. An operation that sets a value converges; an operation that adjusts a value accumulates. This is not a small observation — it is the cheapest lever you have, and reaching for it before reaching for keys and dedupe tables is the difference between one line of SQL and a subsystem.

Note also the two entries where idempotency is about effect and not response. A second DELETE /orders/42 may reasonably return 404, and a well-designed create with an idempotency key returns the same 201 with the same body as the original. Both are fine. The contract is about the system's state, and the response is a design choice layered on top — though returning the original response is far kinder to the caller than returning a distinct "duplicate" error they then have to interpret.

The last row is the one that keeps this module honest. Some effects cannot be made idempotent, only deduplicated before they are triggered: an email sent, an SMS delivered, a webhook you already POSTed to a customer, a physical parcel dispatched. For these, there is no absolute form to reach for. The remembering is the whole mechanism, and it must be reliable before the effect, not after.

Idempotency and deduplication are different tools

They get used interchangeably and they are not the same:

  • Idempotency makes the operation safe to repeat. The duplicate is processed and lands in the same place. No memory is required.
  • Deduplication makes the system detect that this specific message has been seen before, and skips it. Memory is required — a record of what has been seen, for how long.

Prefer idempotency, because it requires no state, has no window to expire, and cannot be defeated by a cache eviction. Fall back to deduplication when the operation is a creation, a relative adjustment, or an external side effect that has no absolute form.


An Idempotency Key Is the Caller's Promise, Not the Server's Guess

When the operation cannot be made naturally idempotent, the caller has to name the attempt. That name is the idempotency key, and three properties make it work.

It comes from the caller. The server cannot derive it. A hash of the request body is the tempting shortcut and it is wrong in both directions: two legitimately distinct requests can be byte-identical — a customer buying the same £4 coffee twice in a minute is not a duplicate — and one logical retry can differ in bytes if the client regenerates a timestamp or reorders a JSON object. Only the caller knows whether this is the same attempt or another attempt.

It is generated before the first send and reused across every retry of that attempt. A key generated inside the retry loop is a new key per attempt and defeats the entire mechanism. This is the most common client-side bug in idempotency, and it is invisible until the day the retries actually fire.

ts

Where does the key come from in a UI? Mint it when the form is rendered or when the user's intent is first formed — not when the request fires. A key created on button-press gives a new key to a double-click, which is exactly the duplicate you were defending against. A key attached to the form instance makes the second click a duplicate of the first, which is what the user meant.

It is scoped to the caller and the operation. A globally unique keyspace shared across tenants means one customer can guess or collide with another's key and receive their response — a data leak dressed as a cache hit. Store the key as (tenant_id, endpoint, key), and never trust the key alone as a lookup.

The fingerprint: what stops the key from being a lie

A caller can send the same key with a different body — usually by accident (a key stored in a variable that outlived its request) and occasionally on purpose. If you return the first response, you have silently ignored the second request; if you process it, the key meant nothing.

So store a fingerprint of the request alongside the key and compare:

sql

Mismatch is a client bug and deserves a loud, specific error — 422 with a message naming key reuse — rather than either of the silent options. Stripe returns exactly this, and the reason it is worth copying is that the alternative failure is undebuggable from the client's side.


Insert the Key to Claim the Work, Because a Read-Then-Write Loses the Race

Here is the implementation that looks obviously correct and is broken:

ts

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.