Preventing Double Form Submission
Why do some customers get charged twice, receive two confirmation emails, or see “that username is already taken” for the account they just created? Because the form was submitted twice: a double click, an impatient second press of Enter, a keyboard shortcut plus a click, a mobile tap that registered twice, or a network retry. This recipe prevents duplicates at every layer: an in-flight guard in the submit handler that ignores repeats, a busy state that keeps the submit button focusable with aria-disabled rather than disabled, a pending-state message, and — because the client can never be the last line of defence — an idempotency key the server uses to turn a duplicate into a harmless replay. Validation stays on the site’s canonical path: the handler calls reportValidity() from the Constraint Validation API before anything is sent.
When Double Submission Matters
Every form that changes state on the server can be submitted twice; it matters most where the effect is not naturally repeatable:
- Payments and orders — two charges, two shipments.
- Account creation — the second request fails with “already exists” for the user’s own new account.
- Messages and posts — duplicates visible to other people.
- Bookings — the same slot reserved twice, or a second request failing because the first took the slot.
Read-only or naturally idempotent actions (a search, a filter, “save settings” that overwrites) need less care, though an in-flight guard still avoids wasted requests. The surrounding lifecycle is covered in the form submission lifecycle.
Minimal Working Double-Submit Guard
const form = document.querySelector<HTMLFormElement>("#order")!;
const button = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
const status = form.querySelector<HTMLElement>("#order-status")!; // role="status"
const keyField = form.querySelector<HTMLInputElement>("input[name=idempotencyKey]")!;
let inFlight: AbortController | null = null;
keyField.value = crypto.randomUUID(); // one key per logical submission
function setBusy(busy: boolean): void {
form.setAttribute("aria-busy", String(busy));
button.setAttribute("aria-disabled", String(busy)); // stays focusable and announced
button.dataset.label ??= button.textContent ?? "";
button.textContent = busy ? "Placing order…" : button.dataset.label;
status.textContent = busy ? "Placing your order. This can take a few seconds." : "";
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (inFlight) return; // repeat while pending: ignore
if (!form.reportValidity()) return; // canonical validation first
inFlight = new AbortController();
setBusy(true);
try {
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form), // includes idempotencyKey
signal: AbortSignal.any([inFlight.signal, AbortSignal.timeout(30_000)]),
headers: { accept: "application/json" },
});
if (res.ok) {
window.location.assign((await res.json()).confirmationUrl);
return; // stay busy while navigating
}
if (res.status === 422) applyServerErrors(form, (await res.json()).errors);
else status.textContent = "We couldn't place your order. Please try again.";
} catch {
status.textContent = "The connection dropped. Your order may have gone through — check your email before trying again.";
} finally {
if (!document.hidden) { inFlight = null; setBusy(false); }
}
});
// Clicks on an aria-disabled button still dispatch events; the guard above handles them,
// but also stop the click from doing anything else (e.g. analytics) while busy.
button.addEventListener("click", (e) => {
if (button.getAttribute("aria-disabled") === "true") e.preventDefault();
});
The key is reused across retries of the same order and regenerated only after success, so a user who retries after a timeout sends the same key and the server can recognise the duplicate. The timeout message is deliberately careful: after a dropped connection the client does not know whether the order was placed, and telling the user to simply “try again” invites exactly the duplicate this page is about.
Guard Option Reference
| Layer | Mechanism | Stops | Notes |
|---|---|---|---|
| Handler | inFlight flag / controller |
Clicks, Enter repeats, shortcuts, requestSubmit() repeats |
The essential client guard |
| Button | aria-disabled="true" + label change |
Visual confusion; keeps focus | Prefer to disabled, which drops focus |
| Status | role="status" message |
Users unsure whether anything happened | One polite announcement |
| Timeout | AbortSignal.timeout |
Forms stuck busy forever | Explain the uncertain outcome |
| Server | Idempotency key | Everything the client misses | The only reliable guarantee |
| Navigation | Post/redirect/get | Resubmission on refresh or Back | 303 after success |
Why not button.disabled = true? A disabled button cannot hold focus, so a keyboard user who pressed it loses their place — focus falls back to the body — and screen readers may not announce the state change. aria-disabled keeps the button focusable and announced as unavailable, while the handler’s guard does the actual blocking.
Verification Steps
import { test, expect } from "@playwright/test";
test("double click sends one request", async ({ page }) => {
let count = 0;
await page.route("**/api/orders", async (route) => {
count++;
await new Promise((r) => setTimeout(r, 500));
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ confirmationUrl: "/done" }) });
});
await page.goto("/checkout");
await fillValidOrder(page);
const btn = page.getByRole("button", { name: "Place order" });
await btn.dblclick();
await expect(btn).toBeFocused(); // aria-disabled keeps focus
await page.waitForURL("**/done");
expect(count).toBe(1);
});
Edge Cases and Failure Modes
Relying on the client only. A flaky mobile connection can deliver the same request twice at the network layer, and a user can open the form in two tabs. The idempotency key on the server is the only guard that covers these.
Resetting the guard too early. Clearing inFlight in finally before a successful navigation completes lets a click during the page transition send a second request. Stay busy on success until the new page loads, as the handler does.
disabled on the button in the submit event. Disabling the submitter before the browser has read it removes its name/value from the submitted data in native submissions. With fetch and FormData(form) built first this is harmless, but in native posts disable after submission starts, or use aria-disabled.
Back button after success. Without a redirect after POST, pressing Back and then Forward can resubmit. Always answer a successful POST with a 303 redirect to a confirmation page.
Two Tabs, One Form
Users open the same checkout in two tabs more often than teams expect — comparing options, returning after an interruption, or restoring a session. Each tab has its own in-flight guard and, if the key is generated on page load, its own idempotency key, so both can create an order. When that matters, issue the key from the server when the cart or draft is created, so every tab showing the same cart submits the same key, and the server’s replay logic collapses the two submissions into one.
Server-Side Idempotency Keys
The server makes duplicates harmless by remembering, for each idempotency key, the outcome of the first request. When a request arrives with a key it has seen, it returns the stored result instead of performing the action again. Store keys with a reasonable expiry (a day is common), scope them to the user so one user cannot replay another’s key, and store the key before performing the action inside the same transaction so two concurrent duplicates cannot both proceed. If a second request arrives while the first is still processing, respond with a 409 and a “still processing” problem type rather than blocking.
export async function placeOrder(req: Request): Promise<Response> {
const fd = await req.formData();
const key = String(fd.get("idempotencyKey") ?? "");
const user = await requireUser(req);
const existing = await idempotency.get(user.id, key);
if (existing?.status === "done") return Response.json(existing.result); // replay
if (existing?.status === "processing") return new Response(null, { status: 409 });
await idempotency.start(user.id, key); // unique index on (user, key)
const result = await createOrderFrom(fd); // validates, charges, stores
await idempotency.finish(user.id, key, result);
return Response.json(result);
}
This is the same principle the server and full-stack validation section applies throughout: the browser improves the experience, and the server guarantees the outcome.
Frequently Asked Questions
How do I prevent a form from being submitted twice?
Guard the submit handler with an in-flight flag so repeats are ignored while a request is pending, show a busy state, and send an idempotency key that the server uses to return the first result for any duplicate request.
Should I disable the submit button after clicking?
Prefer aria-disabled with a changed label. The disabled attribute removes the button from focus, stranding keyboard users, and in native posts can drop the button's name and value from the submitted data.
What is an idempotency key?
A unique value generated for one logical submission and sent with the request. The server stores the outcome for each key and returns it for repeats instead of performing the action again.
What should the user see if the request times out?
Explain that the outcome is uncertain and how to check it, for example by looking for a confirmation email, rather than simply inviting them to try again, which risks a duplicate without a server-side key.
Related Guides
- The Form Submission Lifecycle — where the guard sits.
- Showing a Loading State During Form Submission — the busy UI in depth.
- Using requestSubmit() to Trigger Validation — scripted submissions that the guard also covers.
- Modelling Form Submission with a Finite State Machine — making duplicates impossible by construction.