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.

A double click with and without a guard A timeline comparing two clicks 180 milliseconds apart: without a guard two requests are sent and two orders created; with an in-flight guard the second click is ignored and one order is created. Clicks click 1 click 2 No guard: request 1 POST #1 No guard: request 2 POST #2 With guard: requests POST #1 0 ms 300 ms 600 ms 900 ms 1200 ms
The second click lands while the first request is still in flight; an in-flight guard ignores it, and the server's idempotency key covers anything the client misses.

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.

Submission guard states The form moves from ready to submitting on a valid submit; repeated submits while submitting are ignored; the request ends in success with navigation, or back to ready with field errors or a failure message. ready submitting navigating ready with errors valid submit 2xx 422 / failure fix and submit
While submitting, further submit events have no effect; only a response or a timeout returns the form to ready.

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.

Client guard versus server idempotency Two columns comparing what an in-flight client guard protects against with what a server-side idempotency key protects against. Client in-flight guard ✓ double clicks and Enter repeats ✓ shortcut plus click ✓ instant, no server work ✗ cannot see other tabs or network retries Server idempotency key ✓ network-level duplicate requests ✓ retries after timeouts ✓ multiple tabs, scripted replays ✗ needs storage and a unique index
The client guard stops the common cases cheaply; only the server key stops duplicates from retries, networks and multiple tabs.

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.

← Back to The Form Submission Lifecycle