Double-Click Double-Charge
The Interview Question
"Users are reporting they're being charged twice when they click the 'Pay' button. Customer service is overwhelmed with refund requests. How do you prevent this?"
Asked at: Stripe, Square, Amazon, PayPal, any e-commerce
Time to solve: 25-30 minutes
Difficulty: ⭐⭐⭐ (Mid-Senior)
Clarifying Questions to Ask
- "How is the payment flow currently implemented?" → Identify where duplicates happen
- "Is this frontend or backend issue?" → Both? Usually
- "How quickly do duplicate requests arrive?" → Milliseconds vs seconds
- "What payment processor?" → Stripe, PayPal have built-in idempotency
- "What's our refund rate due to this?" → Priority indicator
The Problem Visualized
Solution: Defense in Depth
You need protection at every layer.
Layer 1: Frontend - Disable Button
// React example
function PaymentButton({ onPay }: Props) {
const [isProcessing, setIsProcessing] = useState(false);
const handleClick = async () => {
if (isProcessing) return; // Guard
setIsProcessing(true);
try {
await onPay();
} finally {
setIsProcessing(false);
}
};
return (
<button
onClick={handleClick}
disabled={isProcessing}
aria-busy={isProcessing}
>
{isProcessing ? 'Processing...' : 'Pay $100'}
</button>
);
}
⚠️ This alone is NOT enough! Users can:
- Disable JavaScript
- Use multiple tabs
- Refresh the page
- Call API directly
Layer 2: Frontend - Idempotency Key Generation
// Generate idempotency key BEFORE user action
function PaymentForm({ cartId }: Props) {
// Generate once when form loads
const idempotencyKey = useMemo(
() => `cart-${cartId}-${Date.now()}-${crypto.randomUUID()}`,
[cartId]
);
const handlePay = async () => {
await fetch('/api/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // Send with every request
},
body: JSON.stringify({ cartId, amount: 100 }),
});
};
return <PaymentButton onPay={handlePay} />;
}
Layer 3: Backend - Idempotency Enforcement
# payment_service.py
import hashlib
import json
from redis import Redis
from datetime import timedelta
class PaymentService:
def __init__(self):
self.redis = Redis()
self.lock_ttl = timedelta(seconds=30)
self.result_ttl = timedelta(hours=24)
def charge(self, request, idempotency_key: str):
# Step 1: Check if we've seen this key before
cached_result = self.redis.get(f"idempotency:{idempotency_key}")
if cached_result:
return json.loads(cached_result) # Return cached response
# Step 2: Acquire distributed lock to prevent concurrent processing
lock_key = f"lock:{idempotency_key}"
lock_acquired = self.redis.set(
lock_key, "1",
nx=True, # Only set if not exists
ex=self.lock_ttl
)
if not lock_acquired:
# Another request is processing - wait or return conflict
raise ConflictError("Payment already in progress")
try:
# Step 3: Process the payment
result = self._process_payment(request)
# Step 4: Cache the result
self.redis.setex(
f"idempotency:{idempotency_key}",
self.result_ttl,
json.dumps(result)
)
return result
finally:
# Release lock
self.redis.delete(lock_key)
def _process_payment(self, request):
# Check database for existing payment
existing = Payment.query.filter_by(
cart_id=request.cart_id,
status__in=['pending', 'completed']
).first()
if existing:
return {"status": "already_processed", "payment_id": existing.id}
# Create payment record BEFORE calling payment provider
payment = Payment.create(
cart_id=request.cart_id,
amount=request.amount,
status='pending'
)
try:
# Call payment provider
result = stripe.Charge.create(
amount=request.amount * 100,
currency='usd',
idempotency_key=f"stripe-{payment.id}" # Stripe's built-in idempotency
)
payment.update(status='completed', stripe_id=result.id)
return {"status": "success", "payment_id": payment.id}
except stripe.error.StripeError as e:
payment.update(status='failed', error=str(e))
raise
Layer 4: Database - Unique Constraints
-- Add unique constraint to prevent duplicate payments
ALTER TABLE payments
ADD CONSTRAINT unique_pending_payment
UNIQUE (cart_id, status)
WHERE status IN ('pending', 'completed');
-- Or use a separate idempotency table
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
response JSONB,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);
Layer 5: Payment Provider - Built-in Idempotency
# Most payment providers support idempotency keys
# Stripe
stripe.Charge.create(
amount=1000,
currency='usd',
source='tok_visa',
idempotency_key='unique-key-per-payment'
)
# PayPal
paypal.orders.create({
"request_body": {...},
"request_id": "unique-key-per-payment" # PayPal's idempotency header
})
# Adyen
adyen.checkout.payments({
"reference": "unique-order-reference", # Adyen uses reference for idempotency
...
})
Complete Flow After Fix
Edge Cases to Handle
1. Network Timeout After Payment Succeeds
def charge_with_retry(request, idempotency_key):
"""
Payment succeeded but response was lost.
Retry should return the same result, not charge again.
"""
for attempt in range(3):
try:
return stripe.Charge.create(
amount=request.amount,
idempotency_key=idempotency_key,
timeout=30
)
except stripe.error.APIConnectionError:
if attempt == 2:
# On final retry, check if charge actually succeeded
charges = stripe.Charge.list(
limit=1,
metadata={'idempotency_key': idempotency_key}
)
if charges.data:
return charges.data[0] # Return existing charge
raise
2. Idempotency Key Collision
def generate_idempotency_key(user_id, cart_id, amount):
"""
Include all relevant parameters to prevent collisions.
Different amounts should have different keys.
"""
data = f"{user_id}:{cart_id}:{amount}:{int(time.time() // 3600)}"
return hashlib.sha256(data.encode()).hexdigest()
3. Idempotency Window Expiration
# What if user tries to pay again after 24 hours?
# The idempotency key has expired, but cart might be stale
def validate_payment_request(request, idempotency_key):
# Check if cart is still valid
cart = get_cart(request.cart_id)
if cart.paid_at:
raise AlreadyPaidError("This cart was already paid for")
if cart.expires_at < datetime.now():
raise ExpiredCartError("Cart has expired, please create a new order")
# Check idempotency
# ...
Testing Idempotency
# test_idempotency.py
import asyncio
import pytest
@pytest.mark.asyncio
async def test_concurrent_payment_requests():
"""Simulate double-click by sending 10 concurrent requests"""
idempotency_key = "test-key-123"
# Fire 10 requests simultaneously
tasks = [
charge_async(amount=100, idempotency_key=idempotency_key)
for _ in range(10)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successes and conflicts
successes = [r for r in results if not isinstance(r, Exception)]
conflicts = [r for r in results if isinstance(r, ConflictError)]
# Only ONE should succeed, rest should be conflicts or return cached
actual_charges = Payment.query.filter_by(
idempotency_key=idempotency_key,
status='completed'
).count()
assert actual_charges == 1, f"Expected 1 charge, got {actual_charges}"
Quick Reference
| Layer | Protection | Implementation |
|---|---|---|
| Frontend | Disable button | disabled={isProcessing} |
| Frontend | Generate key | crypto.randomUUID() |
| API Gateway | Rate limit | Throttle identical requests |
| Backend | Distributed lock | Redis SETNX |
| Backend | Cached results | Redis with TTL |
| Database | Unique constraint | UNIQUE (cart_id, status) |
| Payment API | Provider idempotency | idempotency_key header |
Key Takeaways
- Defense in depth - Every layer should prevent duplicates
- Idempotency key - Generate once, send with all retries
- Database is truth - Create pending record before charging
- Cache results - Return same response for same key
- Lock during processing - Prevent concurrent execution
- Use provider idempotency - Stripe, PayPal support it natively
Remember: The button disable is for UX. Real protection is in the backend.