Disabling buttons isn't enough: server-side duplicate protection
· 7 min read
Grey out the button, add a spinner, and your duplicate rate falls. It does not reach zero, because the browser is not where correctness lives. A retried webhook, a mobile client that resends after a timeout, or someone posting straight to your endpoint will all sail past anything you did in JavaScript. Server-side duplicate protection is what turns "usually one order" into "exactly one order". This article covers the four mechanisms that do the work: idempotency keys, unique database constraints, post-redirect-get, and careful handling of payment retries.
These are developer-level patterns, but the decisions behind them are business decisions, so it is worth understanding what each one guarantees.
Idempotency keys: one intent, one result
Idempotent means an operation can be repeated without changing the outcome beyond the first time. The standard way to achieve it is a key generated once per attempt by the client and sent with the request.
How the flow works
- When the form or checkout page is rendered, generate a unique key, for example a UUID, and put it in a hidden field or send it as a request header.
- On submission, the server looks for that key in an idempotency table.
- If it is absent, insert it inside the same database transaction that creates the order, then process normally and store the response.
- If it is present and the first request finished, return the stored response instead of doing the work again. The caller sees the same order number and cannot tell a retry apart from the original.
- If it is present but still in progress, return a status telling the caller to wait rather than starting a second run.
Details that matter
- The key must be generated once per intent, not per request. If your JavaScript makes a fresh key on every click, the mechanism does nothing.
- Store it with a unique index, so two simultaneous requests cannot both pass the check. Relying on a select-then-insert without a constraint reintroduces the race you were trying to remove.
- Scope keys to the user or session so one customer cannot reuse another's key.
- Expire old keys, typically after 24 hours or a few days, and clean the table on a schedule.
- Cache the response body and status code, not just a flag, so the retry gets the same answer.
Most payment gateways support an idempotency key header on their own APIs. Using theirs protects your calls to them; using yours protects your orders. You want both.
Unique constraints: the last line that never lies
Application code checks for an existing record and then inserts. Between those two steps, a second request can slip in. Under load that is not a rare edge case, it happens.
A unique index in the database is the only check that two concurrent transactions cannot both pass. Put one on whatever defines uniqueness in your domain:
- The idempotency key itself.
- Email or phone number on a users table, after normalising the value, so accounts do not duplicate.
- A composite of customer, slot and date on a bookings table, so one appointment slot is taken once.
- The gateway's transaction reference on a payments table, so a replayed webhook cannot record a second payment.
Then catch the constraint violation in code and turn it into a sensible response: fetch the existing record and return it as success, rather than showing the customer a database error. Write the constraint first and the friendly handling second, never the other way around.
Post-redirect-get: stop the refresh problem
The oldest fix in the list and still the most neglected. After a successful POST, respond with a redirect, usually 303, to a page fetched with GET. The browser's address bar then holds a safe URL, so refresh and back re-request a harmless page instead of re-posting the form.
Implement it properly by storing the result, such as the order number, in the session or by putting the order reference in the redirect URL. If validation fails instead, redirect back to the form with the submitted values and error messages preserved, so the customer does not retype everything.
This pattern also improves analytics, since a dedicated confirmation URL is easy to track as a conversion, and it removes the browser's confusing "confirm form resubmission" dialog entirely.
Payments: where duplicates cost real money
Payment flows deserve their own rules because the money moves in a system you do not control.
Create the order before you charge
Generate your order record with a unique reference first, pass that reference to the gateway, and use it to match everything that comes back. An order that only exists after payment succeeds is impossible to reconcile when the response is lost.
Treat webhooks as repeatable
Gateways retry notifications when they do not receive a prompt success response, and may deliver the same event more than once. Record the event identifier with a unique constraint, verify the signature, and make the handler safe to run twice. Return a fast 200 and do slow work in a queue, otherwise a timeout causes another retry.
Reconcile browser redirect and webhook
The customer's return to your site and the server notification are two reports of the same event, arriving in either order. Both should look up the order by your reference and update it to paid only if it is not already paid. Never create the order from both paths.
Handle the ambiguous cases explicitly
If a charge request times out, do not retry blindly. Query the gateway for the status of that idempotency key or reference first. If two authorisations do exist, void or refund the extra promptly and tell the customer what happened, because a visible hold on their account causes calls whether or not it will drop off by itself.
A checklist before you call the flow safe
- Every write endpoint that creates something has an idempotency key or a natural unique key.
- Unique indexes exist in the database, not only in application validation.
- Constraint violations return the existing record as success, not an error page.
- All successful posts end in a redirect to a GET page.
- Webhook handlers are signature-verified, deduplicated by event id and quick to respond.
- Order records exist before the payment attempt and are matched by your own reference.
- Someone has tested a double submission with the network throttled and with the request replayed by hand.
Build it once and reuse it
The work here is not large, but it has to be deliberate, and it is far cheaper to add while a system is being built than after a month of tangled orders. Implement the idempotency table and the redirect pattern as shared code, then apply them to every endpoint that creates a record. If you are putting together a checkout, a booking system or an internal application where this matters, our software development team builds these safeguards in as standard rather than patching them in later.
Frequently asked questions
Can I just deduplicate by comparing field values?
It works as a rough safety net, for example rejecting identical content from the same customer within two minutes, but it guesses. A key that represents the user's single intent is precise, and a genuine repeat order is never wrongly blocked.
What HTTP status should a repeated request return?
Return the original response: the same 200 or 201 with the same body. Some APIs add a header noting it was a replay. Returning an error would make a correct retry look like a failure and encourage another attempt.
Do I need idempotency on a simple contact form?
Usually not. A unique constraint on something sensible plus the redirect pattern is enough. Reserve full idempotency for actions that cost money, send messages or consume stock.
How long should idempotency keys be kept?
Long enough to cover realistic retries, commonly 24 hours to a week. Keep payment-related keys towards the longer end, since gateway retries and customer support queries can arrive a day or two later.
We can do this for you
See what a package covers and what it costs, or ask us about your own project.

