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.

Repro steps
  1. Enter an amount (e.g. 1000).
  2. Enable **Slow network** (or **Flaky network**).
  3. Double-click **Pay** (or click Pay, then click again while it’s in-flight).
  4. Observe: two requests → two charges (bad).

Buggy (no guard, no idempotency)

Profile: normal
Amount
In-flight
0
Requests sent
0
Charges created
0
Total charged
0
Charges
No charges yet.

What to do

  • Try double-clicking Pay.
  • Turn on slow/flaky network + auto-retry.
  • Watch chargesCreated exceed expectedIntents (one intent → many charges).
Note
A “disabled button” reduces duplicates but doesn’t prevent retries, refresh, or multi-tab. Business correctness requires idempotency keys.

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.
Broken invariant
Invariant: One user intent → at most one committed side-effect.

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.

Detection condition
chargesCreated > expectedIntents

Detector snippet

// 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 snippet

// ❌ 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 snippet

// ✅ 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 result

Note: 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)

Profile: normal
Amount
In-flight
0
Requests sent
0
Charges created
0
Total charged
0
Last key
Charges
No charges yet.

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.
Common pitfall
“I disabled the button, so it’s fixed.” That only prevents the simplest double-click. Retries, refreshes, multi-tab, and reconnects can still create duplicates.

Treat idempotency as a backend contract. UI guards improve UX — correctness lives on the server.