A user clicks Complete Payment two times. How do you prevent duplicate payment?
How I frame it in the interview
Double-click is not a rare edge case — impatient users, laggy mobile networks, and broken loading states make it common. I'd clarify whether payment is card/UPI/wallet via gateway or internal wallet debit, and whether checkout is server-authoritative. Goal: at most one successful charge per order intent, even if two HTTP requests arrive milliseconds apart.
Defence in depth — three layers
1) UI guard (first line, not sufficient alone): Disable the button on first click, show spinner, ignore further clicks while a request is in-flight. Prevents honest double-clicks but not tab refresh, back-forward, or two devices.
2) Idempotency key (the real fix): Client generates one key per checkout attempt (UUID). Every retry of that attempt reuses the same key. Server stores (idempotency_key → response) with a unique constraint. First request processes; duplicates return the same stored outcome without calling the gateway again.
3) Order state machine + row lock: Under the idempotency layer, lock the order row (SELECT FOR UPDATE). Allowed transitions: CREATED → PAYING → PAID. If already PAID, return success without re-charging. Two concurrent requests with different keys still cannot double-pay because the row lock serializes gateway calls.
User (double click) Frontend Backend Gateway
| | | |
|-- click Complete -------->| disable btn + spinner | |
|-- click again (fast) ---->| ignored (client guard) | |
| |-- POST /pay (Key: K1) -->| lookup idempotency(K1) |
| | | miss -> lock order row |
| | | status INITIATED -> PAYING |
| | |-- charge(orderId, K1) -->|
| | |<-- success --------------|
| | | persist response(K1) |
| |<-- 200 same body -------| |
| | | |
| (slow network retry) |-- POST /pay (Key: K1) -->| hit -> return stored 200 |
| | | (no second gateway call) |Implementation details interviewers probe
- Pass idempotency key to the gateway — Stripe/Razorpay/PayPal support this; duplicate API calls at the provider also become safe.
- Same key + different payload → 409 Conflict — detects client bugs; don't silently return a stale response for a mismatched body.
- Timeout ambiguity — user sees spinner forever; client retries with the same key. Never mint a fresh key on retry or you lose dedupe.
- PAYING state — if first request is still at gateway, second returns
409 Payment in progressor polls status; don't start a parallel charge. - Key scope — one key per order checkout session, not per user globally. TTL 24–72h on idempotency store.
// Client: one in-flight payment per checkout session
const idempotencyKey = useRef(crypto.randomUUID()); // stable for retries of SAME attempt
const [paying, setPaying] = useState(false);
async function completePayment() {
if (paying) return;
setPaying(true);
try {
const res = await fetch(`/api/orders/${orderId}/pay`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey.current,
},
body: JSON.stringify({ method: selectedMethod }),
});
// handle success / failure — do NOT mint a new key on timeout retry
} finally {
setPaying(false); // only re-enable if user explicitly starts a new checkout
}
}// Server: idempotency key on the payment intent (sketch)
@PostMapping("/orders/{orderId}/pay")
ResponseEntity<PayResponse> pay(
@PathVariable String orderId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PayRequest body) {
return idempotencyService.execute(idempotencyKey, bodyHash(body), () -> {
return transactionTemplate.execute(status -> {
Order order = orderRepo.findByIdForUpdate(orderId);
if (order.getPaymentStatus() == PAID) {
return PayResponse.alreadyPaid(order);
}
if (order.getPaymentStatus() == PAYING) {
throw new PaymentInProgressException(orderId); // or poll / return 409 + retry-after
}
order.markPaying();
orderRepo.save(order);
ChargeResult charge = paymentGateway.charge(
order.getAmount(),
order.getCurrency(),
idempotencyKey // pass through to Stripe/Razorpay/etc.
);
order.markPaid(charge.getTxnId());
orderRepo.save(order);
return PayResponse.success(order, charge);
});
});
}Walkthrough — double click in 80ms
User double-clicks. Frontend disables button after first click; second click no-ops. One POST with Idempotency-Key: K1. Server sets order to PAYING, charges gateway once, stores response under K1, marks PAID. If the user's network drops before response, client retries with same K1 — server returns stored success; gateway never sees a second charge because provider idempotency key matches.
Weak answer to avoid
"We disable the button after click." That stops casual double-clicks only. A retry after timeout, opening checkout in two tabs, or a race between two parallel requests still double-charges without server-side idempotency and order-state guards.
Follow-up questions
Two requests hit the server at the exact same time with the same idempotency key — race?
Use unique DB constraint on idempotency_key: first insert wins and processes; second blocks on insert or reads completed record. Alternatively INSERT … ON CONFLICT return existing row. Row lock on order prevents parallel gateway calls even with different keys.
User opens checkout in two browser tabs and pays both — same key?
No — each tab should get its own checkout session / order id. Idempotency is per order attempt. Business rule: one active unpaid order per cart; second tab should see order already PAID or PAYING.
First request charged gateway but crashed before saving PAID — retry safe?
Yes if gateway idempotency key was K1 — retry charge with same key returns original result, not a new debit. Reconciliation job polls gateway by order_id / idempotency_key for orphaned charges.
How is this different from duplicate webhook handling?
Double-click is client-initiated duplicate API calls on the same user action. Webhooks are server-to-server retries from the gateway. Same primitives (idempotency + state machine) but client path needs UI guards and stable retry keys; webhook path needs event_id dedupe and fast 200 on replay.
What about optimistic UI that shows Paid before server confirms?
Never mark paid in UI until server returns success. Show processing state. If optimistic, roll back on failure and never fire a second charge — poll order status with the same idempotency context.