Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

Why idempotency keys exist

The network can drop your response after the work is done. Now you have to retry — and you have no idea whether you'd be doing it for the first time or the second. Idempotency keys are the small protocol the client and server agree on so the retry is safe.

Systems intro Apr 29, 2026 · updated Aug 25, 2026 · 12 min read

On this page

The picture version

Five pictures for a reader who has never had to think about what a timeout actually means. The prose below fills in the seams the pictures skip.

1 · The problem

The reply got lost. The money may not have.

your code the payments server POST /charges  $42 the reply never arrives the card may already be charged from your side, these two worlds look exactly the same it ran, and the answer was lost retrying charges them twice it never arrived at all not retrying loses the order The network hides the answer without hiding the work.
Every retry policy you have ever written sits on top of this ambiguity. A timeout is not “it failed” — it is “you have no idea”, and no amount of waiting turns one into the other.

2 · Two fixes that don’t work

The server can’t tell a retry from a second purchase.

idea 1 — the server dedupes on what the request says { amount: 42, customer: 7 } { amount: 42, customer: 7 } a retry of one purchase? or two things bought a minute apart? identical bytes, opposite meanings idea 2 — ask the server for a ticket first, then spend it ask for a ticket server mints one and that reply can be lost in the same way Whoever names the operation must be the side that survives the loss.
The second idea is the more instructive failure: it needs an idempotency mechanism for the ticket endpoint, and then one for that. The recursion only stops when the client does the naming, because the client is the only party that still has the name after a lost reply.

3 · The move

Bring your own ticket, and bring it every time.

the client invents key k before the first send attempt 1 every retry, same k have I seen k before? seen, and finished → replay it never seen → do the work seen, still running → wait, or say “already in flight” and the answer the second attempt gets is the first attempt’s answer the client’s half of the deal: the same key always means the same intended operation the server’s half: remember what that key already produced, and hand it back instead of doing it again generate the key inside the retry loop and every attempt gets a fresh one — the dedupe never fires A dangerous retry has become a lookup.
The key is minted before anything can be lost, which is the whole reason it survives a lost reply. The server is not detecting duplicates; it is being told which operation this is — the client did the identifying, once, up front.

4 · The part that gets built wrong

If the key and the work can commit apart, they will.

two stores, two moments — and a crash can land between them do the work (charge the card) record the key (in the other store) crash here the retry charges again the other order: work that never happened looks done the fix is to remove the gap, not to shrink it one transaction the key row and the business write commit together, so either both are there or neither is and when the work is an external charge, your transaction can’t cover it — so that call carries its own key, and the deduping moves to whoever owns the side effect
This is the step home-grown implementations most often skip, and it is the one that decides whether the dedupe table is a fact or a hopeful log. A key recorded outside the transaction is a record of what you believe happened.

5 · Keep this card

The whole thing on one index card.

idempotency key = client-generated unique ID + server-side dedupe table + the key and the work committing in one transaction without that, the table records what you hoped happened the coat check: you keep the ticket, so a lost reply costs you nothing except that here you print the ticket, because their handing you one is the moment that gets lost duplicates still arrive — the server just refuses to act on them twice
Nothing here makes delivery exactly-once; the duplicates keep coming. What the key buys is effectively-once behaviour on top of at-least-once delivery — and if someone promises you exactly-once, the useful question is where their dedupe lives.

Why it exists

You call POST /charges for $42. The request goes out. The connection times out. Did the charge happen?

You don’t know. The packet that would have told you got lost — but the packet that asked for the work might have arrived just fine. From the client’s seat, “request succeeded but reply was dropped” is indistinguishable from “request never made it.” Both look like a timeout.

So you have to choose, and both choices are bad:

This is the core problem. The network can hide the answer without hiding the work. Before reading on: what could the client put in that second request that would let the server tell “this is the retry of the $42 charge you already ran” apart from “here is a fresh $42 charge”?

Idempotency is the way out: design the operation so retrying it is harmless. For a GET that’s free — reading the same row twice is the same as reading it once. For a write, you usually need help. The help is an idempotency key: a token the client invents and sends along, that the server uses to recognize “I’ve already done this exact request, here’s the same answer I gave last time.”

The key turns a dangerous retry into a safe lookup. We’ll follow that same $42 charge the rest of the way down.

Why it matters now

Anywhere the cost of a duplicate is real, idempotency keys appear:

The pattern shows up wherever a network or process boundary can swallow an acknowledgement. Which is, on a long enough timeline, everywhere.

The short answer

idempotency key = client-generated unique ID + server-side dedupe table

Picture to keep: a coat check. You hand over the coat and keep the numbered ticket. Come back with the same ticket and you get the same coat back — not a second coat, and not an argument about whether you were here before. Except that in a coat check they print the ticket; here you have to bring your own, because the moment they hand you one is exactly the moment that can get lost.

The client picks a unique value (often a UUID) before it sends the request, and reuses that same value for every retry of that logical operation. The server, before doing the work, checks a dedupe store: “have I seen this key?” If yes, return the recorded response. If no, do the work, record the response under the key, then return it. Two halves: the client’s promise that the same key means the same intent, and the server’s commitment to remember.

How it works

Attempt 1: let the server dedupe by content

The obvious move needs nothing from the client. The server hashes the request body — amount, currency, customer — and refuses anything it has seen recently.

Why it breaks: it can’t tell a retry from a repeat. A customer who genuinely buys two $42 things a minute apart sends two byte-identical requests, and the second one silently vanishes. Content identifies the shape of the request; it can’t identify the intent.

Attempt 2: let the server hand out a token first

So make the client ask: POST /charge-tokens returns a fresh token, then the client spends that token on the real request.

Why it breaks: you’ve moved the problem, not solved it. The token response can be lost in exactly the same way the charge response was, and now the client doesn’t know whether it has a token. You’d need an idempotency mechanism for the token endpoint — which is where the recursion should tip you off.

Attempt 3: the client mints the key before it sends anything

That’s the fix, and the reason it has to be this way is worth sitting with. The whole point is to survive the case where the server’s response never arrives — so the client must be able to name the operation before it has any reply to anchor on. A server-generated identifier is fine for referencing a created resource afterwards; it can’t deduplicate the request that created it.

For our $42 charge, with key k:

  1. Client generates k once, before the first attempt. Sends Idempotency-Key: k plus the request body.
  2. Server looks up k in its dedupe store.
    • Hit, with a stored response → return it. The work has already been done; the client just didn’t hear about it.
    • Miss → mark k as in-progress, do the work, record the response under k, return the response.
    • Hit, but still in-progress → either wait for the first attempt to finish, or return a “duplicate-in-flight” error so the client backs off and retries later.
  3. Client retries on a timeout or a 5xx server error, with the same k, and gets either the original outcome or a quick deterministic error.

That’s the mechanism. Every remaining refinement is a hole someone fell into.

Attempt 4: store the key next to the work

A dedupe store is at minimum a map from key to “I’m working on it” or “here’s the recorded result.” Keep it in Redis, say, and write the charge to Postgres.

Why it breaks: the two can disagree. Crash between “did the work” and “recorded key k” and the retry does the work again; crash the other way and work that never happened looks done. The fix is atomicity: put the key in a row of the same database the work writes to, and commit both in one transaction. This is the part most home-grown implementations get wrong.

That fix is complete only when the work is a database write. Our $42 charge isn’t — the card gets charged by a processor across the network, which your transaction can’t roll back. There the honest version is: your transaction covers the key row and your own records, and the external call needs its own idempotency key sent downstream (which is exactly why Stripe offers one). You don’t escape the problem, you push the deduping to the boundary that owns the side effect. The outbox pattern in the seams below is the general form of this.

A common shape:

INSERT INTO idempotency_keys (key, request_fingerprint, status)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING;

If the insert wins, this attempt does the work and updates the row to completed with the response, in the same transaction as the business write. If it loses, the row already exists — and note that the INSERT alone doesn’t tell you what is there. The server still has to read the row back and branch: a completed row means hand over the stored response; an in_progress row means the first attempt is still running, which is the awkward case covered below.

Attempt 5: make sure the same key means the same request

Idempotency keys are a promise about intent, not just identity. If the client sends key k once with {amount: 42} and again with {amount: 4200}, the server should not silently treat the second as a duplicate of the first — that would let a bug or a race quietly overwrite a charge with a different one.

The defensive move is for the server to also store a fingerprint of the request body and reject mismatches. Stripe’s API documents exactly this behavior — replaying a key with a different payload is an error, not a silent dedupe. Anything less is a footgun.

Attempt 6: let keys expire — but not too soon

Storing every key forever is unbounded growth. Most real systems give keys a TTL — Stripe’s v1 docs say keys can be pruned once they’re at least 24 hours old; v2 extends the replay window much further. The retention has to comfortably exceed the client’s worst-case retry budget, or else the client retries with a key the server has already forgotten, the dedupe miss looks fresh, and the operation runs again.

Show the seams

You started with idempotency key = client-generated unique ID + server-side dedupe table. What did the $42 charge force into that line? — + the key and the work committing in one transaction. Everything else here (fingerprints, in-progress rows, TTLs) hardens the contract, but if the key and the charge can commit separately, the dedupe table is just a log of things you think happened.

Going deeper