Get in touch
Want to work on something together?
Just want to chat? Hit me up.
Want to work on something together?
Just want to chat? Hit me up.
This is not a theory post. It is the near-miss I still think about when someone says "we'll just add retries on the client."
I was shipping a payment-related API. Create a charge, wait for the provider, return success. On a good day the path was clean. On a bad day the network lied, the tab froze, or the load balancer cut the connection after the money had already moved.
Then the frontend plan landed: retry failed requests with exponential backoff.
That sentence is fine for a GET. For a POST that takes money, it is how you invent double charges.
Picture the race without protection:
POST /payments.Same user. Same amount. Two charges. Support ticket. Refund. Trust dent.
I had not waited for the frontend PR to merge before adding idempotency. That timing was luck, not virtue. If retries had shipped first, production would have taught the lesson the expensive way.
Reads are mostly safe to replay. Writes that trigger the real world are not:
Payment providers themselves lean on idempotency for the same reason. Your API sits between a nervous client and a money movement. If you do not define what "same request again" means, the client will invent a definition by hammering retry.
Retries are correct engineering. Networks drop. Mobile users background the app. Gateways time out while your upstream is still working.
Exponential backoff is even polite: it stops a thundering herd from finishing what a single timeout started.
The bug is pairing automatic retries with non-idempotent writes. The client cannot know whether the first request died before or after your side effects. So it retries. Your job is to make that retry boring.
Give the client a key it can reuse across attempts for the same logical action:
POST /payments
Idempotency-Key: pay_01j8k3m2n4p5q6r7s8t9
Content-Type: application/json
{ "amount": 1200, "currency": "USD", "orderId": "ord_42" }On first sight of that key:
On a repeat with the same key:
The frontend can retry until it gets a response. The backend treats attempts 2 through N as "show me what I already did."
Bare minimum that saved me:
Optional but useful: a hash of the request body. Same key with a different payload should fail loudly (409 or similar), not silently apply the old result to new input.
When a duplicate arrived after the frontend retries shipped, the path looked successful to the UI and empty of new side effects on my side. That is the whole point.
If I replay the timeline, the safe order was:
Reverse that order and you are load-testing double charges with real users.
Idempotency did not make the API clever. It made retries safe.
Payment APIs, retry logic, and idempotency keys are the same story told from three chairs. The network will fail. The client will try again. Your API either understands "again" or it invents a second payment.
I got lucky on the calendar. Design like you will not.