A real incident, the kind that makes a good cautionary tale: a food delivery platform once let customers place orders for free, for an entire weekend, because of a glitch in how it talked to its payment provider. The provider returned inconsistent error messages for the same underlying failure, the platform’s API couldn’t tell those errors apart, and it ended up creating a fresh order every time instead of recognizing a retry. The fix for this entire class of bug has a name: idempotency.
What idempotency actually means
An operation is idempotent if performing it multiple times produces the same result as performing it once. Call it twice, ten times, a hundred times. nothing extra happens after the first time.
This matters most for write operations, because reads are naturally idempotent (asking for the same thing twice doesn’t change anything). Looking at HTTP methods specifically:
| Method | Idempotent? | Why |
| GET, HEAD | Yes | Read-only |
| PUT | Yes (by spec) | Replaces the resource wholesale. same input, same end state |
| DELETE | Yes | Deleting an already-deleted thing is a no-op |
| POST | No | Creates something new. by default, every call creates another one |
| PATCH | Usually no | Partial update; repeating an “append to array” patch grows the array every time |
POST and PATCH are where the danger lives, and anywhere you have a “place order,” “charge card,” or “submit payment” button, you have a POST that absolutely must not fire twice for one user intent.
How the failure actually happens

The database did its job correctly both times. it has no idea the two inserts came from the same user intent. The failure was entirely in the handoff: the acknowledgment got lost, the client reasonably assumed the request failed, and it retried the only way it knew how , by resubmitting.
The fix: idempotency keys
The client generates a unique key. a UUID is fine. for each logical action, and attaches it to the request as a header. Crucially, if the client retries, it must send the same key, not a new one.
POST /orders
Idempotency-Key: 7f3e2a10-9c41-4b8a-8e2d-114fbb2a9f01
Content-Type: application/json
{ "item": "burger", "quantity": 1 }
The API, before touching the database, checks whether it has already seen that key:
app.post('/orders', async (req, res) => {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).send('Idempotency-Key required');
const cached = await redis.get(`idem:${key}`);
if (cached) {
// Already processed — return the original result, don't touch the DB again.
return res.status(200).json(JSON.parse(cached));
}
const order = await db.orders.create(req.body);
await redis.set(`idem:${key}`, JSON.stringify(order), 'EX', 86400);
res.status(201).json(order);
});

Where to store the keys
A Redis cache sitting in front of the database is usually the right call: it’s fast, and it sits exactly where the API needs to check before paying the cost of a database round trip. A dedicated table in your primary database works too if you don’t already run a cache. the API memory itself is the one place you should not store it, since that disappears the moment the process restarts, which is exactly the situation a retry is likely to follow.
Where this applies in practice
Anywhere a client might legitimately retry a mutating request without being sure the first attempt succeeded: checkout flows, payment captures, anything behind a button a frustrated user might double-click. GET, DELETE, and well-behaved PUT requests generally take care of themselves. The moment you’re creating or incrementally modifying something with POST or PATCH, idempotency keys are the difference between “the network hiccuped” and “the customer got charged twice.”



