Nov 14, 2026
Your form shouldn't submit twice
Every form I have built has the same button problem. The user clicks submit, nothing happens for a second, and they click again. Maybe the network is slow, maybe the server is busy, maybe they just double-clicked out of habit. Either way the same form reaches the server twice, and if the server treats every request as a new command, the user gets two orders, two accounts, or two charges. The bug is not the second click. The bug is a server that cannot tell a retry from a new request.
The first layer is the one everyone writes — a submitting flag that disables the button while the request is in flight:
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
try {
await fetch("/api/orders", {
method: "POST",
body: JSON.stringify(order),
});
} finally {
setIsSubmitting(false);
}
}
Do this. It fixes the double-click, and it gives the user the feedback that stops them clicking again — a button that says "Saving…" is worth more than a disabled button that says nothing. But it is a user-interface fix, not a guarantee. It does nothing for the case that actually costs money: the request reaches the server, the order is created, and the response is lost on the way back. The browser does not know whether the order exists. The user sees an error, clicks submit again, and now there are two orders. Two tabs, a flaky mobile connection, a retry from your own fetch wrapper — all of them get past the disabled button, because the button was never the problem. The client cannot be the last line of defence for something the client cannot see.
The fix is a contract between the client and the server, and it fits in one header: an idempotency key. The client generates a unique key for one logical operation — one order, one signup, one payment — and sends it with the request. The server records the key with the result. When the same key arrives again, the server does not run the work a second time. It returns the original result.
const keyRef = useRef<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) return;
setIsSubmitting(true);
// One key per intended order. Generated once, reused on retry.
keyRef.current ??= crypto.randomUUID();
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": keyRef.current,
},
body: JSON.stringify(order),
});
if (res.ok) {
// Confirmed success: the next order gets a fresh key.
keyRef.current = null;
}
} finally {
setIsSubmitting(false);
}
}
The rule that makes this work is easy to get wrong: generate the key before the first attempt and keep it until you have a definite answer. A new key on every retry is a new operation as far as the server is concerned, and it protects nothing. A key reused for a genuinely new order collapses two orders into one. One intended operation, one key.
On the server, the key has to be enforced where it cannot be raced. A "check, then insert" in application code is a race condition with extra steps — two requests arriving at the same instant both check, both miss, both insert. In PostgreSQL the constraint itself is the lock:
CREATE UNIQUE INDEX unique_order_request
ON orders (user_id, idempotency_key);
// Insert the order and claim the key in one statement.
// ON CONFLICT means the second request inserts nothing.
const result = await db.query(
`INSERT INTO orders (user_id, idempotency_key, total)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, idempotency_key) DO NOTHING
RETURNING id`,
[userId, idempotencyKey, total]
);
if (result.rows.length === 0) {
// This key was already processed — return the original order.
return res.status(200).json({ order: await findOrderByKey(userId, idempotencyKey) });
}
A table that stores only "key seen" is not enough. Store the key with the result, so the replay returns the same status and body the first request got — including the same validation error, if that was the outcome. And scope the key: to the account and the endpoint, so one tenant's key cannot replay another tenant's result, and a key for creating an order cannot be replayed against a refund. A key that arrives with a different payload than the one it was first used with is almost certainly a client bug, and the honest answer is a 409 Conflict, not a silent success.
A few honest limits. Idempotency keys do not replace validation — a bad order rejected once should be rejected the same way on replay, and that is a feature, not a bug. They are for writes; a GET is already safe to repeat, so do not bolt keys onto reads. They need a lifetime: 24 hours is a common default, long enough to cover any real retry and short enough that the table does not grow forever. And they do not fix a broken retry policy on their own — retrying instantly, in a tight loop, still hammers a struggling service. That is the lesson from the retry post: back off, then retry with the same key.
The question to ask about any form that changes something on the server is simple: if this request arrives twice, does the user get charged twice? If the answer is yes, the disabled button is not the fix. Label the operation, remember the label, and let the second request be an answer instead of a second order.