How the Idempotency Saves the
?
a Sunday afternoon in March 2019. Someone in India tries to order food on Uber Eats with Paytm. Their wallet is empty, the payment errors. They tap order again. The food arrives anyway. Word spreads. Within hours, a college in India has run up $14,000 in free orders — in a single day. Uber only finds out when restaurants start going offline under the order volume.
The cause was a tiny change at Paytm: an API endpoint silently went from idempotent to non-idempotent. Uber's integration assumed the old contract still held; the new response Paytm returned wasn't mapped to anything, and the silent default was treat it as success.
This was not a security breach. It was a missing contract: the agreement between client and server that says if you see this request again, treat it as the same one, not a new one. That contract has a name: idempotency. Without it, the web is a sea of retry-happy clients sending duplicate requests no one can recover from. With it, everything keeps working through failure.
GET: Reads Don't Break Things
What you see on the left is the Retry Lab. A client. A server. A wire between them. Below the wire, two panels: the request log is what the client perceives — what requests it sent, what it got back, what it does not know. The server resource is what is actually true on the server. The reason we need both panels is the entire point of this post: sometimes they tell different stories.
Throughout, the same resource lives on the server — a bank account.
{ "id": "acc_42", "owner": "Alice", "balance": 100 }
We will send requests to read and mutate this account, retry them, break the wire mid-flight, and watch what happens. The first scenario is the simplest one: a successful GET. Nothing breaks. Nothing changes server-side.
- 01Client sends GET /accounts/acc_42. The request packet leaves the client and starts its trip across the wire.
- 02Server receives the GET, reads the account, sends 200 back with the account body. Nothing on the server changes — GET is safe.
- 03Response arrives. Client got the data; server is exactly as it was. That is the safe, idempotent baseline.
That is the baseline. GET is — it cannot change the server. And it is idempotent — even if you fire ten GETs back to back, the server is in exactly the state it started in. Most other methods will not be this polite.
Idempotency Is About Server State, Not the Response
Most engineers can recite the textbook definition of idempotency: an operation is idempotent if running it multiple times has the same effect as running it once. Fine. The trap is in the word effect.
Effect on what?
Effect on the server's state, not on the response you receive. A method can return a different response each time it runs and still be idempotent — as long as the server ends up in the same place.
DELETE makes this obvious. Watch:
- 01Client sends DELETE /accounts/acc_42 for the first time.
- 02Server deletes the account. The resource is gone. Server sends back 204 No Content.
- 03Response arrives. Client knows the account is gone. Server agrees.
- 04Client sends DELETE /accounts/acc_42 again. Maybe a retry, maybe a paranoid double-check.
- 05Server looks up the account. It is already gone. Sends back 404 Not Found. The resource stays gone.
- 06Response arrives. 204 the first time, 404 the second — but the server state is the same after both: gone. That is what idempotent really means.
First DELETE: the account exists, the server deletes it, returns 204 No Content. Second DELETE: the account is already gone, the server returns 404 Not Found. Two completely different status codes. Same server state both times: gone.
That is idempotent.
Microsoft's API design guidance defines idempotency in terms of resource state, not response content. Two DELETEs that leave the resource in the same final state are idempotent — even if one returns 204 and the next returns 404.
This also reveals something else: safe and idempotent are not the same property. GET is safe (no state change) and idempotent. DELETE is idempotent (gone twice is still gone), but it is absolutely not safe (it destroyed the account). POST is neither safe nor idempotent. Hold onto that distinction — you will need it in about thirty seconds.
The Wire Breaks → POST: The Double-Charge Trap
In the real world, requests fail. They fail in two distinct ways, and the second one is the entire reason this post exists.
Failure mode 1 — the request is lost. The packet leaves the client and never arrives. The server does nothing. No state change. From the client's side: silence.
Failure mode 2 — the request reaches the server, the server processes it, and the response is lost on the way back. The server has done the work. The client never finds out.
Here is the cruel part: from the client's perspective, both modes look identical. Both feel like "I sent a request and got nothing back." But the server's reality is wildly different in each case — and you have to decide whether to retry without knowing which world you're in.
Now watch what happens when the retried method is POST.
- 01Client sends POST /accounts with {owner: "Alice", balance: 100} to create the account.
- 02Server receives the POST, creates acc_42, sends back 201 Created.
- 03The wire breaks. The 201 response never reaches the client. From the client's side: silence. From the server's side: the account exists, and the server thinks the job is done.
- 04Client gives up waiting and retries. From the client's perspective, maybe the request never landed. From the server's perspective, this looks like a brand-new request.
- 05The server has no way to know this is a retry. It does what POST does: creates a new account. There are now two Alices.
- 06Client gets a clean 201. From its point of view: success. From the server's reality: it processed Alice's signup twice. The duplicate is invisible to the side that thinks it's done.
Two Alices. One user intent, two records on the server. This is the canonical failure that makes idempotency necessary, and this is exactly what happened to Uber Eats — at scale. A third-party payment API's retry-status was mis-read; the same payment kept landing again; the food kept getting delivered; the money kept not getting charged.
PUT: Same Failure, Different Outcome
Now the same broken wire, the same retry, the same intent — but with PUT.
- 01Client sends PUT /accounts/acc_42 with {owner: "Alice", balance: 200} to update the account.
- 02Server replaces the account with the new body. Balance is now 200. Sends back 200 OK.
- 03The wire breaks on the return trip. The 200 never reaches the client. Server: done. Client: silent.
- 04Client retries with the same body. From its view: maybe the first never landed. From the server: another PUT — same as the previous one.
- 05Server applies the PUT again. Same body, same result — balance stays 200. The retry is a no-op on the server.
- 06Response arrives. Client got a clean 200. Server has one account, balance 200 — exactly what would have happened with one PUT. That is what idempotent buys you.
Watch what just happened. The network broke in exactly the same way. The client retried in exactly the same way. The server received the same request twice — exactly like before. The outcome is different: one record on the server, balance 200, exactly as if the client had sent one PUT.
The difference is the method's contract. PUT says: here is the new full state of this resource — make it look like this. Two PUTs with the same body land the resource in the same place as one PUT. The retry is a no-op on state. That is what idempotent means in practice, and that is why PUT survives where POST does not.
PATCH: The Trap Inside the Trap
Is PATCH idempotent? The honest answer is: it depends on what the patch body does.
PATCH is a partial update. The verb itself does not specify how you describe the partial update. You could say "set this field to 200" — a declarative patch. You could say "add 50 to this field" — an imperative, delta-style patch. Both are valid PATCH bodies. They behave very differently under retry.
Start with the declarative one.
- 01Client sends PATCH /accounts/acc_42 with {balance: 200}. A declarative patch — "set the balance to 200."
- 02Server applies the patch. Balance is now 200. Sends 200 OK.
- 03Wire breaks on the return trip. Response is lost. The server state is still {balance: 200}.
- 04Client retries with the same body: {balance: 200}.
- 05Server applies the patch again — sets balance to 200. It was already 200. Nothing changes on the server.
- 06Response arrives. Balance is 200. Whether the patch ran once or twice, the answer is the same. A declarative patch is idempotent.
Same body, same outcome. { balance: 200 } sets the balance to 200 — twice in a row, still 200. Just like PUT, but with a smaller payload. So far, PATCH looks idempotent.
Now change one word in the body.
- 01Client sends PATCH /accounts/acc_42 with {credit: 50}. An imperative patch — "add 50 to the balance."
- 02Server credits 50 to the balance. 100 + 50 = 150. Sends 200 OK.
- 03Wire breaks on the return trip. Response is lost. Balance on the server is now 150 — but the client does not know that.
- 04Client retries with the same body: {credit: 50}. Same intent. Same payload. The body says "add 50" — and the server is about to do that again.
- 05Server credits another 50. Balance is now 200. Alice has been credited twice for one user intent.
- 06Response arrives. Client got a clean 200. Balance is 200, not 150. The runaway is real. PATCH is not idempotent when the body is a delta.
The balance is 200 instead of 150. The retry doubled the credit. Same client intent — but the body says add, not set, and the server obediently adds twice. Your user wanted a single $50 deposit; they got a single $50 deposit's worth of side effect, applied twice.
Both Microsoft's API design guide and Google Cloud's REST best-practices stress this: PATCH's contract is determined by the patch document, not by the verb. A delta-style patch is fundamentally not idempotent.
So here is the interview answer, distilled: "PATCH's idempotency is determined by the patch body, not by the verb. A declarative {balance: 200} is idempotent — functionally a small PUT. An imperative {credit: 50} is not — deltas accumulate under retry."
Idempotency Keys: Making POST Survivable
Sometimes the operation you need genuinely cannot be idempotent. POSTing a payment, charging a card, sending an email — these are creation events with side effects in the real world. PUT is not what you want. So how do you retry POST safely?
You attach an idempotency key: a unique identifier the client generates per logical operation, sent as an HTTP header. The server keeps a small table of seen keys mapped to the responses it returned. When a request arrives with a key it has seen before, the server skips the work and replays the cached response.
- 01Client generates a UUID and sends POST /accounts with header Idempotency-Key: 9b3f… and body {owner: "Alice", balance: 100}.
- 02Server looks up the key 9b3f… in its dedupe table. Not found. Processes the POST, creates acc_42, stores key → response in the table, sends 201 Created.
- 03Wire breaks on the return trip. The 201 never reaches the client. Server has acc_42; key 9b3f… is in the dedupe table.
- 04Client retries — same POST, same Idempotency-Key: 9b3f…. The key is what tells the server "this is a retry, not a new request."
- 05Server looks up 9b3f… in the dedupe table — finds it. Skips the create entirely. Replays the cached 201 from the first request. No second account.
- 06Response arrives. Client got a clean 201, exactly what it expected. One account on the server. The key absorbed the failure. POST is now survivable.
Same broken wire as before. Same retry. But this time the second POST is recognized — the key 9b3f… is in the server's dedupe table — and the server skips the create entirely. The client gets the cached 201. One account on the server. Crisis averted.
This pattern is what Stripe famously documented and what most well-designed payment APIs require for retries. Done right, it gives POST the safety of PUT without losing its creation semantics.
The matrix that emerges:
| Method | Idempotent? | How it's upheld |
|---|---|---|
| GET | Yes | The handler must not mutate state |
| PUT | Yes | Whole-resource replacement — body is the final state |
| DELETE | Yes | Target-state convergence — gone twice is still gone |
| POST | No (by default) | Bolt it on with an Idempotency-Key header |
| PATCH | Depends on the body | Declarative ({ balance: 200 }) yes; delta ({ credit: 50 }) no |
None of these are enforced by HTTP. They're contracts your handler keeps or breaks.
Back to Uber Eats: The Missing Idempotency Key
This is exactly the missing piece in March 2019. Paytm silently changed the behavior of a charge-retry endpoint — what used to be idempotent stopped being so. Uber Eats had no way to recognize that the second hit on a failed payment was a retry of the first; the response shape was new, the silent default mapped it to success, and the food kept getting delivered.
The fix — both then, and in any system that takes retries seriously — is a key-based contract between client and provider, where every payment attempt carries an idempotency key, and the provider's response is anchored to that key. Under that contract, the same retried payment is unambiguously the same payment, regardless of what new response the provider introduces next quarter.

HTTP methods are promises you make about your endpoint's behavior, and the infrastructure of the web assumes you keep those promises. Idempotency isn't something HTTP grants for free to verbs like GET, PUT, or DELETE — it's a property you uphold through how you write the handler and how you design the contract. Break that contract and you can certainly do it — but you've made your endpoint un-cacheable, un-retryable, and dangerous in the face of the very behaviors the contract was designed to enable safely.