Engineering

Double-Click, Double-Charge:
Why "One Click" Becomes Two Requests

6 min read

A user clicks "Pay." The spinner lags. They click again. Or their mobile connection drops for a second and the app retries.

Now you have two payment attempts, two orders, and two side effects. No malicious intent. No "buggy user." Just normal human and network behavior.

This pattern isn't unique to user interfaces. The same problem affects Stripe webhooks, Zapier automations, and API duplicate records — anytime requests can be retried, you need idempotency.

🚨 The Reality: Clients Retry. Humans Retry.

Duplicates come from two primary sources that you cannot control:

  1. Humans: double-click buttons, refresh pages, re-submit forms.
  2. Clients and Networks: mobile SDKs retry automatically, browsers re-send on navigation edge cases.

🔁 The Classic Failure Timeline

  • Client sends POST /checkout
  • Server processes payment / creates order
  • Response is slow or lost over the network
  • Client doesn't know it succeeded
  • Client retries
  • Your server executes again

❌ The Tempting Fix: Frontend Button Locking

Yes, you should disable the button. But it is not enough. Refresh bypasses UI states, mobile SDK retries happen at the networking layer, and proxies retry on your behalf. Frontend fixes reduce duplicates — they don't eliminate them.

✅ The Only Real Fix: Idempotency

You need a guarantee on the server side: The same action request can be repeated but executed exactly once.

🔧 Example: Checkout That Can't Double-Charge

POST /checkout
Idempotency-Key: checkout_{{user_id}}_{{cart_session_uuid}}
Content-Type: application/json

{
  "cart_id": "c_123",
  "amount": 4900
}

On the server, call OnceOnly POST /v1/check-lock with the same key. If it’s a duplicate, don’t charge again — return your cached order response.

Your API response example:

{
  "status": "success",
  "action_id": "act_checkout_xyz",
  "executed_at": "2026-01-24T18:20:00Z",
  "duplicate": false,
  "result": {
    "order_id": "ord_789",
    "amount": 4900,
    "payment_status": "succeeded"
  }
}

On second click with the same idempotency key, your API returns the cached response (because OnceOnly returns status=duplicate) — but no second charge is created.

🚀 The 5-Minute Fix

Add an idempotency layer designed for real-world production behavior — retries, double-clicks, and mobile flakiness.

It guarantees safe retries and no duplicate side effects. In 2026, "one click" becoming two requests is normal. Processing both is optional.

Make Your Checkout Safe

Integrates with Stripe, Braintree, and custom APIs in minutes.

❓ Frequently Asked Questions

Isn't disabling the button enough to prevent double-clicks?

No. Disabling buttons helps with intentional double-clicks, but doesn't protect against: page refreshes, browser back/forward buttons, mobile app state restoration, network-layer retries from SDKs, or proxy/CDN automatic retries. You need server-side protection.

How do I generate a good idempotency key?

Combine stable identifiers with a unique session ID: checkout_{user_id}_{cart_session_uuid}. The key should be the same for retries of the same logical action, but different for genuinely new actions. Generate it client-side and send it with every request.

What if the user actually wants to make two purchases?

Each unique purchase intent should have a different idempotency key. When a user adds items to their cart and starts checkout, generate a new session UUID. If they complete that purchase and start a new one, it gets a new UUID. Same action = same key; different action = different key.

Does Stripe's API already handle idempotency?

Stripe's API supports idempotency keys for creating charges, customers, and other resources. But if you're using Stripe webhooks or building checkout flows on top of Stripe, you still need to implement idempotency in your own application logic.

What about mobile apps with flaky connections?

Mobile SDKs often implement automatic retry logic at the networking layer. Your backend has no visibility into whether a request is a genuine retry or a new action. This is why server-side idempotency keys are critical for mobile apps—they're the only way to distinguish retries from new requests.

How long should I store idempotency keys?

Typically 24 hours is sufficient for checkout flows. This gives users time to retry after failures while preventing indefinite storage growth. For different use cases, adjust the TTL: immediate actions might need 1 hour, while subscription changes might need 7 days.

Can database unique constraints replace idempotency keys?

Database constraints prevent duplicate inserts, but they don't prevent duplicate side effects like charging a payment processor, sending emails, or triggering webhooks. You need idempotency at the business logic layer, not just the database layer. See our guide on preventing duplicate API records.