Idempotency keys: the one write that makes retries safe
A network timeout does not mean the server did nothing. An idempotency key identifies the same request so the server replays the first result instead of executing again.
A client sends a payment, the connection times out, it retries. If the first attempt actually succeeded, the money moved twice. A timeout is not a failure — that is the premise of every retry.
What the key does
The client generates one unique key per business intent (a UUID) and reuses it on retries:
POST /payments
Idempotency-Key: 6f4a1e2c-9b3d-4c8a-9f21-7e5d0b3a1c88
Server logic: insert a row keyed on that value. The first request really runs and stores its result; later requests with the same key return the stored result.
Implementation notes
async function handle(key: string, body: Body) {
try {
const row = await db.insert('idempotency', { key, state: 'in_progress' });
} catch (e) {
if (!isUniqueViolation(e)) throw e;
// already exists: return the saved result or say it is running
const existing = await db.get('idempotency', key);
if (existing.state === 'done') return existing.response;
return conflict('in progress');
}
const result = await doWork(body);
await db.update('idempotency', key, { state: 'done', response: result });
return result;
}
Three details that get skipped:
| Detail | If missing |
|---|---|
| Unique index at the database | concurrent requests both insert and both run |
| Store the original response body | a retry has to recompute, defeating the point |
| Keep an in_progress state | cannot tell running from finished |
Scope and lifetime
Bind the key to the authenticated principal plus the endpoint. Otherwise one user’s key can hit another user’s record.
Lifetime is a business decision: payments usually keep keys for a day to a few days. Too short drops genuine retries; too long grows the table forever. Clean up outside the business transaction so you never delete an in-flight record.
Request fingerprint
A strict implementation also stores a hash of the body to catch key reuse with different content:
if (existing.requestHash !== sha256(body)) {
return unprocessable('key reused with different payload');
}
422 fits better than 409 here. It is a client programming error, not a state conflict.
When you do not need it
Naturally idempotent operations need no key: GET, a full-replacement PUT, DELETE. POST that creates a resource, anything that charges money, and anything that sends a message do. The test is whether running it twice produces two side effects.
An idempotency key does not make retries safe by itself. It gives the server a way to recognize that this is already the same request.

Comments
…