Q26 — How do you handle idempotency for write APIs?
Why does this problem exist at all?
Networks are unreliable. A client sends a "charge card" request, the server processes it, and then the connection drops before the response arrives. The client doesn't know if the charge happened. So it retries.
Now you've charged the card twice.
This isn't a bug in the client. Retrying on failure is the correct behavior — without it, every network hiccup causes silent data loss. The problem is that the server treated two requests as two separate intents, when they were the same intent expressed twice.
The root cause: the server has no way to distinguish "a new charge" from "a retry of an earlier charge" on its own. HTTP gives you a method and a payload. Neither tells you about intent history.
Why can't the server detect duplicates itself?
Your first instinct might be: compare payloads. If someone sends { user: 123, amount: 50 } twice, they probably mean the same thing.
But they might not. A user who fat-fingers $50 twice in a row is two separate charges. A subscription that bills $50 every month is the same amount every time. Payload similarity tells you nothing about intent.
You could look at timestamps — reject requests within N seconds of each other. But what if a legitimate retry happens 10 seconds later, or a legitimate second charge happens 2 seconds later?
The server fundamentally cannot infer intent from the request body alone. Only the client knows whether a request is a retry or a new action.
Why must the client provide an idempotency key?
Because the client is the only party that knows "this is the same intent I expressed before."
When the user clicks "Pay," the client generates a UUID and attaches it to the request:
Idempotency-Key: 7f3b2c1a-...
POST /charge
{ amount: 50 }
If the response never arrives and the client retries, it sends the same key. If the user clicks "Pay" again for a new purchase, it generates a new key.
The key is scoped to one specific human intent — not to the session, not to the user, not to the operation type. One intent, one key.
Why store the result, not just the key?
You might think: just record which keys you've seen. If you've seen the key before, reject the request.
The problem is that idempotency is not about rejection — it's about returning the same outcome. The client is retrying because it never got a response. If you just return a 409 on retry, you've told the client "something happened" but not what — it still doesn't know if the original charge succeeded.
So you store the full response alongside the key:
idempotency_key | status | response_body
7f3b2c1a-... | COMPLETED | { charge_id: "ch_abc", status: "success" }
On retry, you return the stored response verbatim. From the client's perspective, the retry was indistinguishable from a successful first attempt.
Why do you need a database unique constraint on the key?
Consider two retries arriving at almost the same millisecond. Both hit the server simultaneously. Both query the database: "has this key been seen before?" Both get back "no." Both start processing the charge. You've charged the card twice again.
The application-level check ("has this key been seen?") isn't enough because two concurrent reads can both return "not seen" before either write completes.
The fix: make the database enforce uniqueness. You try to insert a row with the key — the insert either succeeds (you won), or fails with a unique constraint violation (someone else won). The database guarantees only one insert succeeds, no matter how many concurrent requests race to do it.
The "loser" thread then reads the existing row and returns whatever the "winner" stored.
Why do you need a status field, not just a stored response?
The winner thread inserts the key, starts processing the charge, and then crashes before it can write the response.
Now the row exists with no response. The loser thread reads it and finds... nothing. What should it do? Start processing again? That defeats the point.
You need to model this explicitly with a status:
PROCESSING— key is claimed, work is in flightCOMPLETED— work finished, response is storedFAILED— work finished, but with an error (also stored)
A retry that finds PROCESSING knows not to start the work again. It either polls briefly or returns a 202 Accepted — "we know about this, wait for the result."
This also raises the question: what if PROCESSING never transitions? The first worker crashed and nobody is completing the work. You need a timeout on PROCESSING — after some reasonable duration (say, 30 seconds beyond the max expected processing time), a stuck row can be treated as failed, and a retry is allowed to restart the work. This requires careful coordination with your actual processing logic.
Why set an expiry on idempotency records?
You can't keep every key forever — the table would grow without bound and slow down lookups.
More importantly: a retry arriving 30 days later is almost certainly not a legitimate retry of the original request. It's either a bug or a genuinely new intent. Honoring a month-old idempotency record might cause wrong behavior, not prevent it.
A typical window is 24–72 hours — long enough to cover any realistic retry window, short enough to keep the table lean. After that, keys are deleted or archived, and the same key could be reused (though clients should use fresh UUIDs anyway).
One more gap: what if the payload changes on retry?
A client might accidentally send the same idempotency key with a different payload — a bug in their code, or a payload that's dynamically generated.
You should reject this: store a hash of the original request payload alongside the key. On retry, if the payload hash doesn't match, return a 422. Silently honoring a different payload under the same key would mean ignoring the new request entirely, which is a silent correctness failure.
The complete mental model
Client intent changes → new key
Client retries same intent → same key
Server receives key:
↓
Insert row (unique constraint)
├── Insert succeeded → first request → run business logic → store result → COMPLETED
└── Insert failed → retry → read existing row
├── COMPLETED → return stored response
├── PROCESSING → wait / 202
└── FAILED → return stored error
Every design decision flows from one truth: the server cannot know intent; it can only remember what it did the last time a given intent arrived.