Where duplicates come from
- Double clicks and taps while a slow request is in flight.
- Automatic retries after a timeout, when the first request actually committed.
- A lost response: the server saved the order, the 200 never arrived, and the client shows an error that invites a second try.
- Re-renders that fire the same effect twice.
The third case is the nasty one. The client cannot tell "failed" from "succeeded but I never heard back", so retrying blindly creates a duplicate.
Fix 1: lock the UI while the request is in flight
function PlaceOrder({ cart }) {
const [pending, setPending] = useState(false);
async function submit() {
if (pending) return;
setPending(true);
try { await api.placeOrder(cart); }
finally { setPending(false); }
}
return <button onClick={submit} disabled={pending}>
{pending ? "Placing order…" : "Place order"}
</button>;
}
Cheap and worth doing, but it only covers clicks in one tab, and it does nothing for retries or lost responses.
Fix 2: send an idempotency key
Generate one key per user intent, not per request, and send it with every attempt:
const key = crypto.randomUUID(); // created when the user starts checkout
await fetch("/api/orders", {
method: "POST",
headers: { "Idempotency-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(cart),
});
Now any retry of the same intent carries the same key, and the server can recognise it. This is how payment APIs make retries safe.
Fix 3: deduplicate on the server
Store the key with the result of the first successful request, then answer repeats with that stored result instead of doing the work again. Use a unique constraint so two concurrent requests with the same key cannot both commit, and expire keys after a sensible window.
Fix 4: coalesce duplicates at runtime
Even with all of the above, some endpoints ship without a key. GenClass watches every request with its causal chain. When it sees the same mutation repeated with the same body moments later, it can coalesce the repeat into the original request in guard mode, so one order is placed. It only retries a failed write when the request is idempotent or carries an idempotency key.
[GenClass] Coalesced POST /api/orders with #212, so one order is placed
On real apps it had never seen, model 0.2.0 at its default setting detected 3 in 4 duplicate submits in observe mode. See the benchmarks.
Checklist
- Disable submit buttons while a mutation is in flight.
- Send an idempotency key per user intent on every mutating request.
- Enforce the key server-side with a unique constraint.
- Never auto-retry a non-idempotent POST without a key.
- Monitor duplicates in production, because the edge cases will find you.
Related: race conditions in React and out-of-order responses.