Every API that sits behind an unreliable network eventually faces the same problem: a client sends a request, the connection drops before the response arrives, and the client has no idea whether the operation happened.
Did the payment go through?
Did the order get created twice?
The client's only safe move is to retry — which means your server needs a story for what happens when the same "create this thing" request arrives more than once.
That story is idempotency keys, and getting the details right is more subtle than it first looks.
The core idea The client generates a unique token — typically a UUID — once per logical operation, and attaches it to every retry of that operation: The server's job is to guarantee that no matter how many times a request with that key arrives, the side effect (charging a card, creating an order, sending an email) happens at most once, and every retry gets back the same response the original request would have produced.
Note what this is not: it is not deduplicating by request body.
Two requests with identical bodies but no key are legitimately two different orders for two widgets.
The key is what marks them as "the same attempt," not the payload.
The naive approach, and why it breaks A common first pass is a table like: On each request: check if the key exists, and if so return the cached response; otherwise do the work and insert the result.
This looks right and is wrong in a specific way: it has a race condition.
Two retries can arrive concurrently (a client that timed out and fired a second attempt while the first was still in flight), both miss the cache check, and both execute the underlying operation.
You've now charged the card twice.
Making the check-and-do atomic The fix is to claim the key before doing the work, using the database's own concurrency control rather than an application-level check: If the returns a row, you won this key — proceed with the operation, then update the row with the real response and flip to .
If it returns nothing, someone else already claimed this key.
Now you have three sub-cases to handle explicitly: status = 'completed' — return the stored response verbatim.
This is the retry-after-success path, and it's the one people design for. status = 'in_progress' — another request with the same key is still executing right now, most likely a genuinely concurrent retry (client-side timeout that fired a duplicate before the first attempt returned).
The correct response here is usually with a "retry shortly" hint, not silently blocking, because blocking ties up a connection for as long as the original request takes and can cascade under load. status = 'failed' — the original attempt errored out before completing.
Whether this is safe to retry depends on whether the failure happened before or after the side effect committed, which is exactly why the next section matters.
Ordering the side effect and the key update The dangerous window is between "the side effect happened" and "the key record says it happened." If your payment provider charges the card and then your process crashes before writing , the key is stuck at (or , if you have a crash handler) forever, and a legitimate retry will either be rejected or — worse, if you designed the failed-state to allow retry — will charge the card again.
Two practical ways out: Same transaction, when possible.
If the side effect is itself a database write (create an order row), do it in the same transaction as the key update.
Either both commit or neither does, and there's no window at all.
External side effect, idempotent downstream.
If the side effect is a call to a third party (a payment processor), pass that call an idempotency key too — most payment APIs (Stripe, Braintree, Razorpay) support this natively.
Then your recovery path for a crashed row is: re-issue the downstream call with the same downstream key.
If it already happened, the processor returns the original result instead of double-charging.
Your own key table bec