Modern HTML5 in Production · Forms That Work
Preventing Double Submissions
Why customers were charged twice when they double-clicked 'Pay', and how to combine HTML/JS safeguards with server-side idempotency.
Scenario
Example app: the "Pay" button submits a standard HTML form. On slow mobile connections, nothing seems to happen for a few seconds, so people tap again.
What you see in production
- About 0.5% of orders are duplicated, each with its own card charge.
- Duplicates cluster on mobile networks and during peak traffic, when responses are slow.
- Refunds and support time cost more than the bug looked worth fixing.
The naive code
<form method="post" action="/pay">
<button>Pay 49.00</button> <!-- can be clicked again while the request is in flight -->
</form>
Reproduce it
Click Pay several times quickly in each version. The simulated server takes 1.5 seconds and records each request it receives.
<style> form { border: 1px solid #ddd; padding: 8px 12px; margin: 8px 0; border-radius: 8px; } </style>
<form id="naive"><strong>Naive</strong> <button>Pay 49.00</button> <span></span></form>
<form id="safe"><strong>Protected</strong>
<input type="hidden" name="idempotency_key">
<button>Pay 49.00</button> <span></span>
</form>
<p id="server">Server charges: 0</p>
<script>
const charged = new Set(); let charges = 0;
function fakeServer(key) { // a slow server that honours idempotency keys
return new Promise((resolve) => setTimeout(() => {
if (key && charged.has(key)) return resolve("duplicate ignored");
if (key) charged.add(key);
charges++; document.getElementById("server").textContent = "Server charges: " + charges;
resolve("charged");
}, 1500));
}
// Real pages handle the form's "submit" event; this preview blocks form submission, so we listen for clicks.
document.querySelector("#naive button").addEventListener("click", async (e) => {
e.preventDefault();
document.querySelector("#naive span").textContent = await fakeServer(null);
});
const safe = document.getElementById("safe");
// generated once per checkout (crypto.randomUUID() also works, but only on secure https pages)
safe.elements.idempotency_key.value = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
safe.querySelector("button").addEventListener("click", async (e) => {
e.preventDefault();
const button = safe.querySelector("button");
if (button.disabled) return;
button.disabled = true; button.textContent = "Processing...";
safe.querySelector("span").textContent = await fakeServer(safe.elements.idempotency_key.value);
button.textContent = "Paid";
});
</script>
Root cause
Nothing stopped a second request: the button stayed active while the first request was in flight, and the server treated every request as a new payment. Network retries (browser, proxies, mobile apps) can duplicate requests even without double clicks.
The fix
Defend on both sides:
- In the page: disable the submit button (and show progress) on the first submit; re-enable it if the request fails.
- On the server: give each checkout an idempotency key (a hidden field generated when the form is rendered, or a key sent in a header) and store it with the result. A repeated key returns the original result instead of charging again. Payment providers support idempotency keys too.
- After a successful POST, redirect to a confirmation page (Post/Redirect/Get), so refreshing doesn't resubmit.
Trade-offs
- Disabling buttons alone isn't enough: JavaScript may fail, and retries happen below the page. The server-side key is the real guarantee.
- Keys need storage and an expiry policy; scope them to the user and the operation.
Detect it in production
- Alert on identical orders (same customer, amount and items) within a short window.
- Track how often idempotency keys are reused: it tells you how often duplicates would have happened.
Key points
- Users double-click and networks retry; duplicates are guaranteed without protection.
- Disable the button on submit and use a server-side idempotency key.
- Use Post/Redirect/Get so refreshes don't resubmit.
Practice questions
How do you prevent duplicate form submissions?
Answer
Disable the submit button and show progress on the first submit, and enforce idempotency on the server with a unique key per operation stored with its result, so repeats return the original result. Use Post/Redirect/Get to avoid resubmission on refresh.
Why isn't disabling the submit button enough?
Answer
JavaScript might not run, users can resubmit from another tab, and browsers, proxies or apps can retry requests. Only server-side idempotency guarantees a single effect.
What is the Post/Redirect/Get pattern?
Answer
After processing a POST, the server responds with a redirect to a GET page (such as a confirmation), so refreshing or going back doesn't resubmit the form.
Open in the app to mark lessons complete, take notes and track your progress.