2026-06-10 · 3 min read
Idempotency keys, and the quiet cost of getting them wrong
A retry is not a duplicate, until it is. Notes on making write paths safe to repeat without turning your database into a coordination bottleneck.
Every system that takes money or sends a message eventually meets the same problem: the client sent a request, the network swallowed the response, and now nobody knows whether it happened. The client retries. If the server is naive, the customer gets charged twice.
The usual answer is an idempotency key: the client picks a unique token, sends it with the request, and the server promises that two requests with the same token produce one effect. Simple to say. The interesting part is where the promise actually lives.
The naive version, and why it bites
The first version everyone writes looks like this:
def charge(idempotency_key, amount):
if store.exists(idempotency_key):
return store.get(idempotency_key)
result = payment_gateway.charge(amount) # side effect
store.put(idempotency_key, result)
return result
Read, then act, then write. The window between the exists check and the put is wide open. Two concurrent retries both see "no key yet," both call the gateway, and you have charged twice anyway. The idempotency key bought you nothing because the check and the effect are not atomic.
Put the uniqueness where the durability is
The fix is to make the key claim and the effect share a single point of serialization. A unique constraint in the same transaction that records the work is the cheapest version:
-- The unique index does the coordination for you.
INSERT INTO idempotency (key, status, created_at)
VALUES ($1, 'in_progress', now())
ON CONFLICT (key) DO NOTHING
RETURNING id;
If the insert returns a row, this request owns the key and may proceed. If it returns nothing, someone else got there first, and you wait on or return their result. The database, not your application code, is now the thing deciding who wins.
| Approach | Atomic? | Cost |
|---|---|---|
| Read-check-write | No | A race per retry |
| Unique constraint | Yes | One index write |
| Distributed lock | Yes | A second system to operate |
The state that nobody talks about
The hard case is the request that claims the key, calls the gateway, and then crashes before recording the result. Now the key says in_progress forever, and every retry blocks on a ghost.
You need a third state and a reconciliation path:
in_progresswith a lease that expiressucceededwith the cached responsefailedthat is safe to retry under a new attempt
The lease is the part people skip, and it is the part that pages you at 3am.
An idempotency key is not a deduplication trick. It is a tiny state machine you are agreeing to operate, forever, for every write that matters.
What I tell people to check
- The key claim and the effect serialize on the same thing.
- There is an expiry on
in_progress, not justsucceeded. - The cached response is the whole response, including the status code.
- You have decided, on purpose, what a key collision across two different requests should do.
None of this is exotic. It is just the difference between a retry that is safe and a retry that is a second charge, and the gap between them is exactly as wide as the code you didn't make atomic.