Click once, charge twice
A checkout that tags every request for safe retries can still bill one mug twice, or ship the wrong order.
Contents6 sections
An idempotency key is a unique ID sent with a request, so the server can spot a retry and return its first answer. It stops a double charge only if it lives exactly as long as the customer’s intent to pay. A new key on every retry charges twice. A key kept too long makes the next order replay the last one.
A new key on every retry
This checkout sends a key with every request, and still charges twice for one mug. Tap Pay, tap Try again when it fails, and count the charges.
One mug, one tap on Pay
Scripted simulation, no real paymentsEvery request from this page carries an Idempotency-Key. It starts with a new key on every attempt.
1 × Mug, $20
Network
- No requests yet
Bank statement
- No charges
At first, the fetch wrapper (the function that sends each request and retries it) makes a fresh key for every attempt. The server charges request 1, but the response is lost. Try again brings a key the server has never seen, so it charges again.
// api.ts
async function post(url: string, body: any) {
for (let i = 0; ; i++) {
// Bug: a new key on every attempt
const key = crypto.randomUUID();
try {
return await fetch(url, {
method: 'POST',
headers: {
'Idempotency-Key': key,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(8000),
});
} catch (e) {
if (i >= 2) throw e;
}
}
}
The fix is to move crypto.randomUUID() out of the loop. Create the key on the first tap, and reuse it until the server gives a final answer. The second setting in the figure does this, and the bank shows 1 charge.
The same key on a new cart
In the second setting, hit the error, tap Back, add the hat and pay $35. In a single-page app (SPA), Back only changes the screen, so the old key is still in memory. The server sees that key and replays its stored answer for the $20 mug. The phone shows “Paid” for $35, built from the cart it sent. The hat is never charged and never ships.
Which layers can send a payment again?
On its way from the phone to the bank, a payment request can be sent again by at least 7 different pieces of software.
| Who sends the copy | Which key it carries |
|---|---|
| The user, tapping Pay again | A new one, unless the app kept the key |
| Your fetch wrapper, after a timeout | A new one, if the key is made inside the loop |
| Chromium, when a reused connection drops | Yours |
| Workbox (a library that stores failed requests in the browser), up to 7 days later | Yours |
| stripe-node (Stripe’s Node.js client), on your server | Its own |
Go’s net/http, on a reused connection |
Yours |
nginx (a reverse proxy), only with non_idempotent set |
Yours |
A copy that carries your key is safe if the server locks the key before doing any work. A copy with a new key looks like a new payment, and stripe-node’s own key can’t fix that. The user and the fetch wrapper make those copies.
When should the key be created?
The figure tries 6 moments to create the key, against 8 scripted scenarios.
How long the key lives
Scripted model of 8 scenarios, not a real serverin an SPA, until a full reloadA common tutorial pattern
3 of 8 ok. Same as per submission on rows 1–7, and it also loses tomorrow’s purchase.
filled: key lived too briefly; hollow: key lived too long
Wrong order: the phone shows success for a cart the server never charged. Lost order: a deliberate second purchase swallowed as a duplicate. Stuck: a 422 the user can’t resolve.
Two taps 150 ms apart, with 1.5 s of processing.
Attempt 1 is processed but its response is lost, so it hits the 8 s timeout. The phone is offline for the wrapper’s two retries. Try again happens 5 s later, online.
Attempt 1 is processed. The reused connection resets before any headers arrive, and the browser resends the same request 0.3 s after the original, while the original still has about 1.2 s to run. With per-attempt keys, the server’s 409 reply (“still in progress”) for the copy surfaces as an error, and Try again creates a new key. If the server made the copy wait instead, the per-attempt cell would be ok.
Processing takes 4 s. The user reloads at 2 s and pays again at 6 s.
Double charge: the key lived too briefly, so a copy of one purchase was charged as a second one.
As the timeout row, then Back at 10 s, add a hat, and pay at 15 s.
Wrong order: the phone shows success for a cart the server never charged.
Two tabs on the same cart. Tab A pays, with 6 s of processing. Tab B pays 3 s later.
Double charge: the key lived too briefly, so a copy of one purchase was charged as a second one.
Attempt 1 is processed, and the connection drops at 3 s. The phone is offline for 3 days. The service worker queues every failed attempt and replays them all on day 3.
Double charge: the server had already dropped the key when the day-3 replays arrived; with 7 d or 30 d retention this cell is ok.
A normal purchase, then the same mug 20 h later, in the same tab, without a reload.
Lost order: a deliberate second purchase swallowed as a duplicate.
Rules every row follows
- The fetch wrapper has an 8 s timeout and 2 retries.
- A 409 reply means “still in progress”. Clients that hold a key retry a 409 with the same key after 1 s. The per-attempt UI treats any failed reply as an error.
- After the last scripted step, if the phone shows an error, the user taps Try again once. Row 7 is the exception: the user has closed the app.
- The phone renders "Paid" from client state.
- The server locks the key first, returns 409 while a request with that key is still running, and replays the response once it’s finished.
- The app is a single-page app.
It opens on “per page load”, a pattern common in tutorials. In a multi-page app, that key died with the page. In an SPA, it lives for the whole shopping session.
Per intent does better. The key is created when the customer commits to this purchase, saved, and reused for every retry of it. I wrote down the same rule in a 2026 note on offline design for a sales app on phones. Each write the user makes is queued with its own client mutation ID. The server can then treat a retry as the same write.
The last option gets all 8 rows right because it stops depending on how long a key lives. The checkout itself becomes a record on the server.
Can the server catch a reused key?
Partly. The IETF’s draft for the Idempotency-Key header says a key reused with a different body should get a 422 error. To tell, the server can store a fingerprint, a hash of the body. Stripe also rejects a reused key with different parameters.
But if the client treats the 422 as a fresh start, it makes a new key and charges $35 on top of the $20. In the figure, tick the 422 check in the server settings and pick per submission. Row 5 flips to double charge. Before it sends anything new, the client must ask what happened to the earlier payment.
Put the checkout on the server
When an intent outlives one request, give it a database row. POST /checkouts creates a draft, and PUT changes its cart. POST /checkouts/:id/confirm moves it from draft to paying to paid. A unique constraint, a database rule, allows one checkout per cart. A second tab gets the same checkout, and a late replay finds it paid.
Stripe’s PaymentIntent, its record of one payment, works this way. Creating one charges nothing, so it can be keyed on the cart. The charge happens at confirm, under a short-lived key.
The server has its own half. This handler has 4 bugs.
app.post('/pay', async (req, res) => {
const key = req.get('Idempotency-Key');
// (a) two copies can both miss
const seen = await db.findKey(key);
if (seen) return res.json(seen);
// (b) not in the transaction
await bank.charge(req.body.cents);
const order = await db.save(req.body);
// (c) crash before here: no record
await db.saveKey(key, order);
// (d) no fingerprint check
res.json(order);
});
The worst is (b). The charge sits outside the database transaction, the writes that succeed or fail together, so a crash before (c) leaves a charge with no record.
Brandur Leach’s 2017 design still fixes all 4. Lock the key first, and reject a key whose parameters differ. Save a recovery point before each call to another system, so a background job can finish abandoned requests. Keep keys longer than any client retries. Workbox can retry for 7 days, but Stripe may drop a key after 24 hours.