When a client's POST /charges times out, it has no way to know whether the charge went through — the response never arrived, but the server may have finished the work. So it retries. The only thing standing between that retry and a second charge is whether the operation is idempotent: safe to repeat because a duplicate request produces the original result instead of a new side effect. Build that in before the network forces the question, because timeouts are not an edge case — they're a Tuesday.
The mechanism is a client-generated idempotency key. The client sends a unique key with each logical operation and reuses the same key on every retry of that operation. The server stores the key alongside a fingerprint of the request and the response it produced, so a duplicate returns the stored response instead of executing again.
POST /orders
Idempotency-Key: 0f3e8c2a-...
| Retry sees | Server response |
|---|---|
| Same key, same payload | Original stored result |
| Same key, different payload | 409 Conflict — key reused for a different request |
| New key | Process once, store result |
Idempotent is not the same as safe
These two HTTP properties get conflated, and the difference decides which operations even need a key:
| Property | Means | Methods |
|---|---|---|
| Safe | No observable state change at all | GET, HEAD, OPTIONS |
| Idempotent | Repeating yields the same result | GET, PUT, DELETE, and POST with a key |
Every safe method is idempotent, but not every idempotent method is safe: DELETE /orders/42 changes state, yet calling it five times leaves the same single deletion. POST is neither by default — which is exactly why it's the verb that needs an explicit idempotency key bolted on.
PUTis nominally idempotent, but the guarantee lives in your handler, not the verb. APUTthat appends a row or bumps a counter on every call is not idempotent no matter what the RFC says. The method sets the expectation; your code has to honor it.
The race the naive version misses
Storing key → response isn't enough on its own. Two retries can arrive milliseconds apart — the first hasn't finished writing its result when the second checks the store, finds nothing, and starts a second execution. Now you've charged the card twice despite having idempotency "implemented."
The fix is to make the key claim atomic. Insert the key with an in-progress status under a unique constraint before doing the work; a concurrent retry that hits the duplicate-key error either waits or gets a 409. Mark it completed with the stored response once the work lands. Give the record a retention window that matches real retry behavior — Stripe, for reference, expires idempotency keys after 24 hours — and protect that key store with the same durability as the operation it guards. An idempotency layer that loses its keys on a restart is a double-charge waiting for a deploy.
Cover photo by Stanislav Kondratiev on Pexels.
