← Blog

Engineering

The Second Click Charged Them Twice

6 min read

The request worked. The user couldn't tell. So they clicked again.

Pay button. User taps it. The spinner is tiny, or the network is bad, or they're in a lift, and the screen sits there. They tap it again.

Both requests were valid. Your handler ran the charge twice. No exception, no red test, nothing in the logs that looks like a bug. The customer still got billed twice, and they're the one who has to notice.

Most fetch examples stop at one call and one response. That's the demo. A POST that takes money, creates an order, or sends an email is not safe to run a second time just because the first one felt slow.

The happy path is the easy part

Here is the function every blog post stops at.

async function pay(amount) {
  const res = await fetch('/api/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount }),
  })
  return res.json()
}

It works on a good connection, with one patient click. It falls over in boring ways, and none of them throw.

The user clicks twice before the first response lands. Two requests leave the browser. Both look like a real payment.

Or the charge goes through, and the response never makes it back. Card is billed, order is saved, the browser shows a timeout. The user hits Pay again. That's what I'd do too, if an app told me it failed.

Or some layer in the middle retries for you. A proxy, a service worker, a wrapper that retries 500s because someone read that retries are "resilient." The retry is a new POST. The server has no reason to treat it as the old one.

Disabling the button only covers the double click, and only until they refresh. A timeout still looks like failure. A retry still looks like a new payment.

A disabled button is not a fix. It's a loading state. The second request can still happen.

Name the intent, not the request

The fix is dull. It's an idempotency key.

The user decided to pay once. That decision gets an ID. Every request for that decision sends the same ID. The server keeps the first result and returns it if the ID shows up later. It does not charge again.

This is not a request ID. A request ID changes every fetch. This ID has to survive the second click, the timeout, and the retry. You only throw it away when the user is obviously starting a different payment.

On the client, create it once, and keep it.

function createPayment(amount) {
  const key = crypto.randomUUID()
  let pending = null

  return function submit() {
    if (pending) return pending

    pending = fetch('/api/pay', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
      },
      body: JSON.stringify({ amount }),
    }).then(async (res) => {
      if (!res.ok) throw new Error('payment failed')
      return res.json()
    })

    return pending
  }
}

Two clicks share one in-flight promise and one key. The second click doesn't fire another request. If the first call failed, you're not done yet. That's the next bit. If it succeeded, both clicks see the same result.

The bug is putting randomUUID() inside pay(), so every call looks like a new decision.

async function pay(amount) {
  const key = crypto.randomUUID() // new intent on every call
  return fetch('/api/pay', {
    method: 'POST',
    headers: { 'Idempotency-Key': key },
    body: JSON.stringify({ amount }),
  })
}

That key changes on every click. You've labelled the exact case you were trying to collapse.

A refresh throws the key away

Keeping the key in memory is not enough. The spinner hangs, they refresh, the component mounts again, crypto.randomUUID() runs again. New page, new key. The server can't tell this is the same payment.

Keep the key with the attempt, not with the page load.

function keyFor(attemptId) {
  const storageKey = `pay:${attemptId}`
  const existing = sessionStorage.getItem(storageKey)
  if (existing) return existing
  const created = crypto.randomUUID()
  sessionStorage.setItem(storageKey, created)
  return created
}

attemptId has to be stable for this checkout. Order ID, cart ID, invoice ID. Not Date.now(). If the ID is the current time, a refresh is a new payment again.

Session storage goes away with the tab. Fine if checkout lives in that tab. If they might open the same payment link tomorrow, put the key on the order when checkout starts, on your server. The browser should read a key you already saved. If the page invents it, a reload can lose it.

The server is the only place this is real

The client will get this wrong. Tabs get killed. People switch phones. Your own retry helper will mint a new key one day. The check that matters is on the server.

First time you see a key, run the charge and store the response. Every later time you see that key, return the stored response and leave the card alone.

const seen = new Map()

async function pay(req, res) {
  const key = req.get('Idempotency-Key')
  if (!key) return res.status(400).json({ error: 'missing idempotency key' })

  const existing = seen.get(key)
  if (existing) return res.status(existing.status).json(existing.body)

  const body = await chargeCard(req.body) // the only line that moves money
  const saved = { status: 200, body }
  seen.set(key, saved)
  res.status(200).json(body)
}

Don't ship the Map. Two requests can both miss seen.get before either writes. A restart wipes it. A second Node process never sees it.

Use a unique row. Insert the key first, in the same transaction as the charge, or insert a "started" row and let the unique constraint reject the copy. The one that loses the race reads the row that won and returns that body. A map in one process falls over the week you scale to two.

Store the response, not a flag. The client that timed out needs the original order ID. "Already processed" with no ID is how you get a support ticket and a second order.

Retries should reuse the key

Retries are fine once the key stays put. Calling pay() again is how a new key sneaks in, if pay generates one. Retry the same request.

async function submitOnce(key, amount) {
  const res = await fetch('/api/pay', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': key,
    },
    body: JSON.stringify({ amount }),
  })
  if (!res.ok) throw new Error('payment failed')
  return res.json()
}

Call that function again on a timeout. Don't rebuild the request inside the retry helper. That's the other way a new key appears.

Timeouts and dropped connections: retry. A 500 is trickier. The server might have charged the card and crashed before it saved the key. Same key, retry. New key, you just double-charged. If you can't send the same key, don't retry.

Don't retry a 400. The body was wrong the first time. It'll be wrong again, and you'll wait longer to see it.

Not every POST deserves this

I wouldn't bother on a search box. GET is already fine to repeat. A view counter off by one is not a phone call from the bank.

I would bother when the user can tell it happened twice:

  • A charge, a refund, or a payout
  • Creating an order, a booking, or a subscription
  • Sending an email or a push, if sending it twice is embarrassing
  • A webhook handler. The sender will retry you. That is not a maybe.

Webhooks are the one people skip, because there's no button. Stripe and Razorpay will send the same event more than once. If the handler inserts a row on every call, you fulfill one order twice. You hear about it from the customer.

What I'd ship

No library. When checkout opens, write one key on the order and send that key on every attempt. Disable the button while a request is out, so the double tap is harder, and don't pretend that's the real guard. On the server, a unique row stores the key and the response. A second request gets the first body back. A timeout retries that same key. A refresh must not start a new charge.

The fetch is the easy line. The second click is the one that costs money.

Find me on X.