I keep finding the same pattern in checkout code. The Pay button calls fetch('/api/checkout', { method: 'POST' }), the button stays enabled, and nothing in the handler checks whether a request is already running. On a slow connection people click twice all the time, and the second click sends a second charge.
What I do now: a pending flag with an early return, the button disabled while it's set, and an idempotency key on the Stripe call built from the order id. Webhooks get a unique constraint on the event id.
What do you do? Do you rely on the button state, the server, or both? Has anyone been bitten by a retry after a timeout where the first charge had actually gone through?
Top comments (1)
state-transition and disable on click, server end gated for single use. That way the user gets immediate feedback and the server side cant process on accident, no matter how small the window was.