The precise problem: a user clicks Submit twice on a slow connection — or presses Enter while a click handler is also bound — and the form issues two identical POSTs, creating two orders, two charges, or two records.

This builds directly on the submit lifecycle described in submission state and optimistic updates; here we isolate the single concern of never letting one user intent become two server writes. The defense has two layers that must both exist: a synchronous client guard that stops most duplicates before they leave the browser, and a server-deduplicated idempotency key that catches the ones a client guard structurally cannot.

Context and Prerequisites

You need the submit controller’s in-flight lock from the parent page — the boolean checked before the first await. If you have not read submission state and optimistic updates, start there, because the idempotency key lives in the same controller closure that owns that lock, and the key’s reuse-across-retries rule only makes sense against that machine.

Why the Client Guard and the Key Are Both Required

The client guard stops duplicates that originate in one browser tab within one in-flight window. It cannot stop: a user who submits, loses the response to a dropped connection, and resubmits from a fresh page load; two tabs open on the same form; or a retry after a 5xx. Those are exactly the cases the server-side key catches. Neither layer is redundant — the guard makes duplicates rare and cheap, the key makes the rare survivors harmless.

/**
 * A minimal double-submit-safe wrapper around a network call.
 * Combines a synchronous in-flight guard with a per-submit idempotency key.
 */
export function createIdempotentSubmit<T, R>(
  send: (payload: T, key: string, signal: AbortSignal) => Promise<R>
) {
  // Synchronous lock. JavaScript runs each function to completion before the
  // next task, so nothing between here and the first `await` can be interleaved
  // by a second call — that is what makes this check race-free, unlike a
  // disabled attribute which the browser only applies after the next paint.
  let inFlight = false;

  // One key per logical submit. Held in the closure so a retry reuses it and a
  // succeeded-but-unacknowledged request is deduplicated server-side, not
  // re-applied. Regenerating per attempt would silently reintroduce duplicates.
  let key = "";

  // AbortController lets an unmount cancel the request so its resolver never
  // touches a torn-down form; also used to supersede a stuck attempt.
  let controller: AbortController | null = null;

  return {
    isInFlight: () => inFlight,

    async submit(payload: T): Promise<R | undefined> {
      if (inFlight) return undefined;      // the guard: drop the duplicate, no throw
      inFlight = true;
      key = crypto.randomUUID();           // mint once, before any await
      controller = new AbortController();

      try {
        return await send(payload, key, controller.signal);
      } finally {
        // Release on EVERY path — success, throw, or abort — or the form locks
        // permanently after the first error and the user can never resubmit.
        inFlight = false;
      }
    },

    /** Reuse the SAME key so the server treats a retry as the same intent. */
    async retry(payload: T): Promise<R | undefined> {
      if (inFlight || !key) return undefined;
      inFlight = true;
      controller = new AbortController();
      try {
        return await send(payload, key, controller.signal);   // key unchanged
      } finally {
        inFlight = false;
      }
    },

    dispose(): void {
      controller?.abort();                 // stop an in-flight request on teardown
    },
  };
}

And the request side, sending the key as a header the server deduplicates on:

async function sendOrder(payload: OrderInput, key: string, signal: AbortSignal): Promise<Order> {
  const res = await fetch("/api/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // Standard-ish header; the server stores this key and, for any repeat
      // within its window, replays the first response instead of writing again.
      "Idempotency-Key": key,
    },
    body: JSON.stringify(payload),
    signal,
  });
  if (!res.ok) throw res;                   // let the caller's parseError route it
  return res.json() as Promise<Order>;
}

Step-by-Step Walkthrough

  1. First click acquires the guard. submit() sees inFlight === false, flips it to true, and mints a key — all synchronously, before the first await. Any code path that yields to the event loop happens only after the guard is held.

  2. The duplicate click is dropped. A second click in the same in-flight window hits if (inFlight) return undefined and returns without issuing a request or throwing. Returning silently — rather than throwing — keeps the duplicate invisible to the user, which is the correct behavior.

  3. The key travels with the request. send() puts the key in the Idempotency-Key header. The server records it keyed to the resulting write and, for any later request bearing the same key, replays the stored response instead of executing again.

  4. The guard releases in finally. Whether the request resolves, throws, or aborts, inFlight returns to false. Skipping this on the error path is the single most common way to permanently lock a form after one failure.

  5. A retry reuses the key. If the first attempt failed transiently, retry() sends the identical key. If the original write had actually succeeded and only its acknowledgement was lost, the server recognizes the key and returns the original result — no second order.

The two guards protect against different attackers, which is why neither one alone is enough:

Which guard stops which duplicate An impatient double click within the same page session is stopped by the in-flight client guard, and the request never leaves the browser. A retry after a network timeout leaves the client guard satisfied — the first request is considered finished — so only the idempotency key prevents a second charge. A page refresh while the request is in flight destroys the client guard along with the page, so the key is the only remaining defence. Two tabs open on the same form have entirely separate client guards, so again only the key helps. The conclusion is that the client guard is a user-experience feature and the key is the correctness feature. How the duplicate happens Client guard Idempotency key Charged twice? impatient double click stops it never reached no retry after a timeout already cleared stops it no refresh mid-request gone with the page stops it no the same form in two tabs separate per tab stops it no Read the first column downwards: the client guard wins exactly one of four rows. It is a responsiveness feature, not a correctness one. Read the second: the key wins every row it is asked about, which is why it belongs on the request rather than in the component.

Failure Modes and Edge Cases

1. The Enter-key double fire

Pressing Enter in a single-line input triggers the form’s implicit submission. If a click handler is also bound to the button, both can fire and race the guard.

// Bind ONE path. Make the button type="submit" and handle the form's onSubmit;
// do not also attach an onClick to the button. Enter and click both funnel here.
<form onSubmit={(e) => { e.preventDefault(); void controller.submit(values); }}>
  {/* ... */}
  <button type="submit">Place order</button>
</form>

Even if both paths did fire, the synchronous guard collapses them into one request — but funneling through a single handler removes the ambiguity entirely.

2. Guard never releases after an early return

An early return before the finally, or a set that throws, can leave inFlight stuck at true. The form then silently ignores every future submit.

// Wrong — an early return skips the reset and locks the form forever.
async submit(payload) {
  if (inFlight) return;
  inFlight = true;
  if (!isValid(payload)) return;   // BUG: inFlight is still true
  await send(payload);
  inFlight = false;
}

Validate before acquiring the guard, and only ever release inside finally.

3. Idempotency key regenerated on retry

A retry that mints a fresh key defeats deduplication precisely when it matters — the lost-acknowledgement case — producing a duplicate write.

// Wrong: new key per attempt makes every retry a brand-new server intent.
async retry(payload) {
  key = crypto.randomUUID();   // BUG — must reuse the existing key
  return send(payload, key, controller.signal);
}

Mint in submit(); leave key untouched in retry().

4. Two browser tabs, same form

Two tabs each hold their own inFlight boolean, so the client guard cannot coordinate across them. Only the server-side key stops the duplicate.

Derive the key from a submission identity the server can recognize — or accept that cross-tab dedup is the server’s job and make sure the Idempotency-Key window is long enough to cover a realistic user round-trip.

5. Non-idempotent server without key support

If the endpoint does not honor an idempotency key, the client guard is your only defense and it does not survive a page reload. Treat this as a server bug, not a client one.

Until the server supports it, gate the risky operation behind a server-issued single-use token fetched on form load and consumed on submit, which achieves the same one-write guarantee.

The key’s lifetime is the detail that decides whether the guard actually holds. It must outlive the request, and it must not outlive the intent:

When to mint a new key, and when to keep the old one The key is generated on the first submit press and stored somewhere that outlives the page, such as session storage, so a reload can find it again. Every retry of that same payload reuses it, which is what makes the retry safe. A confirmed response discards it. Editing the form and submitting again mints a new one, because a changed payload is a new intent and reusing the key would make the server return the first result for the second request. first submit press mint a UUID and persist it retries and reloads reuse the same key — this is the point server confirms discard the key and the snapshot reader edits mint a new key — new intent Two ways to get the lifetime wrong Minting on every click: each retry gets a fresh key, the server sees unrelated requests, and the reader is charged per click. Never rotating it: the reader corrects a typo, resubmits, and the server cheerfully replays the first — wrong — result. Deriving the key from a hash of the payload gets both right for free, at the cost of retries after an edit looking like new requests. Store it beside the draft, not in component state, so a crash mid-request does not lose the only thing making the retry safe.

Verification Checklist

The submit button as a state machine

Everything above is easier to keep straight if the button is treated as a small machine with four states, because each state answers “is a click allowed right now” differently:

Four button states, and which of them accept a click Ready: the button is enabled, a click starts the request and moves to submitting. Submitting: the button is disabled and shows a busy state, clicks are rejected by both the disabled attribute and the in-flight guard, and the response moves it to succeeded or failed. Succeeded: the button is disabled and the key is discarded; this is terminal until the reader edits the form. Failed: the button is enabled again and a click retries with the same key, which is exactly what makes the retry safe. ready click accepted click submitting disabled + guarded 2xx 4xx / 5xx succeeded key discarded failed key retained retry — same key Only "submitting" needs the disabled attribute; "succeeded" is disabled because there is nothing left to submit, not to prevent a duplicate. Never model this with a boolean: "not submitting" collapses three states that behave differently and hides the retry path entirely.

Frequently Asked Questions

Why is disabling the submit button not enough on its own?

The disabled attribute only applies after the framework commits the next render, which is at least a tick after the click. A second click or an Enter keypress fired in that gap still reaches the handler. Disabling is correct for feedback — it tells the user something is happening — but the actual guarantee is a synchronous in-flight boolean checked before the first await, backed by a server-side idempotency key for the duplicates a client guard cannot see, such as a resubmit after a lost response.

Where should the idempotency key be generated — client or server?

The client generates it, once per logical submit, and reuses it across retries. Only the client knows that two requests represent the same user intent; the server sees two independent HTTP calls. Generating it server-side would give each retry a distinct key and defeat deduplication for the exact case idempotency exists to cover — a request that succeeded but whose acknowledgement was lost on the network. crypto.randomUUID() is sufficient; the key needs to be unique per intent, not secret.

How do I stop the Enter key from submitting twice?

Enter in a single-line input triggers the form’s implicit submit, which can coincide with a click handler bound to the button. Bind one submit path — the form’s onSubmit — and make the button type="submit" so Enter and click both funnel through the same handler. The in-flight guard then collapses any residual overlap into a single request. Do not attach a separate onClick to the submit button; that is what creates the second path in the first place.


Related

Submission State and Optimistic Updates