Double submit: the $1000 bug
A double-click, a slow network, or an automatic retry can create duplicate charges/orders. UI guards help, but real safety requires idempotency keys so the backend can dedupe repeated requests.
Break it: double-click submits twice (duplicate charge)
Set amount to 1000, enable slow/flaky network, then double-click **Pay**. In the buggy version, you’ll often get two charges.
- Enter an amount (e.g. 1000).
- Enable **Slow network** (or **Flaky network**).
- Double-click **Pay** (or click Pay, then click again while it’s in-flight).
- Observe: two requests → two charges (bad).
Buggy (no guard, no idempotency)
What to do
- Try double-clicking Pay.
- Turn on slow/flaky network + auto-retry.
- Watch chargesCreated exceed expectedIntents (one intent → many charges).
Why this breaks
Mental model: intent must be applied at most once
- The UI is not a reliable gate: users can double-click, browsers can retry, and requests can be re-sent.
- If the backend treats each request as a new action, duplicates create real business damage (double charge / duplicate order).
- Disabling the button reduces probability, but doesn’t protect against retries, refreshes, or multi-tab submits.
- Correctness requires idempotency: the same intent must be applied at most once.
Detected issue
Detect it by comparing user intents vs completed actions. If one intent can create multiple charges, correctness is broken.
expectedIntents = number of user Pay intents (clicks), not number of network attempts.
chargesCreated > expectedIntents// If 1 intent leads to 2 charges → bug
const bug = chargesCreated > expectedIntents;Reference (correct mental model)
Correct mental model
Incorrect / Buggy
- Assume the UI can reliably gate submissions.
- Treat every request as a brand new action.
- Ignore retries and duplicated delivery.
// ❌ Buggy: no guard, no idempotency
function onPay() {
api.charge({ amount }); // double-click -> two charges
}Correct
- Model each click as a unique user intent.
- Generate an idempotency key per intent.
- Return the same result for repeated keys.
// ✅ Correct: guard + idempotency
// UI guard: prevent concurrent submits in this tab
if (inFlight) return;
setInFlight(true);
const key = crypto.randomUUID(); // per user intent (persist if needed)
api.charge({ amount, idempotencyKey: key })
.finally(() => setInFlight(false));
// Server dedupes:
// if (seenKey) return previousResult; else create charge and store resultNote: The idempotency key must be stable per user intent. If retries occur (network, refresh, auto-retry), the same key must be reused — generating a new key would reintroduce duplicate charges.
Fix: UI in-flight guard + idempotency key (dedupe on the server)
The fix is visible: duplicates stop occurring under double-clicks and retries. UI guards reduce concurrency — idempotency guarantees correctness.
Fixed (UI guard + idempotency)
Why it’s safe
- UI guard prevents concurrent submits in this tab.
- Each intent gets an idempotency key.
- Server dedupes: same key returns the same charge result.
- Retries don’t create duplicates.
Key takeaways
- Double-clicks and retries are normal — design for them.
- UI guards reduce probability but do not guarantee correctness.
- Idempotency enforces “apply intent at most once”.
- Return the same result for the same key.
- This is a business correctness bug — not just a UI bug.
Approach & tradeoffs
Approach
- Add an in-flight guard (disable button / lock per tab).
- Generate an idempotency key per user intent.
- Store key → result on the backend.
- Handle retries and show a stable success state.
Tradeoffs
- Requires backend support and storage.
- Needs a TTL / cleanup strategy.
- Key scope matters — wrong scope can over-dedupe.
Treat idempotency as a backend contract. UI guards improve UX — correctness lives on the server.