How to stop duplicate form submissions
Double clicks, slow networks and retries all send the same form twice. Give each submission an ID when the form loads and let the server drop the repeats.
Updated
Every form that takes leads eventually stores the same one twice. The visitor double-clicks the button. The request is slow, so they press it again. Your server route times out, retries, and the first attempt had actually worked. Each of these sends the team two emails, puts two records in the CRM and, if you forward leads to a partner, bills you twice.
Disabling the button helps with the first case and does nothing for the others. The fix that covers all of them is an ID for the submission itself.
Give the submission an ID when the form loads
Make a random ID once, when the form first renders, and send it with every attempt to submit that form. A retry of the same submission carries the same ID; a new visit, or the same visitor filling the form in again tomorrow, gets a new one.
<form id="quote">
<input type="hidden" name="submission_id" />
<!-- your fields -->
</form>
<script>
document.querySelector('#quote [name="submission_id"]').value =
crypto.randomUUID();
</script>In React, keep it in state so it survives re-renders but isn’t shared between visitors:
const [submissionId] = useState(() => crypto.randomUUID());Don’t make the ID from the form’s contents (a hash of the email, say). Two genuine requests from the same person would collapse into one.
Let the server drop the repeats
The server remembers which IDs it has stored. When one arrives again, it answers as if it had just stored it, and does nothing else: no second record, no second email, no second forward.
With 1DB this is built in. Send the ID as submission_id with the X-1DB-Contract: 2 header:
curl -X POST https://gallant-goshawk-513.convex.site/v1/leads \
-H "Content-Type: application/json" \
-H "X-API-Key: $DB_API_KEY" \
-H "X-1DB-Contract: 2" \
-d '{
"submission_id": "3f9c2a91-7d4e-4b8a-9c11-0e6f5b2d8a47",
"formSlug": "quote-request",
"email": "dana@northfieldsupply.com"
}'The first time, 1DB stores the lead:
{ "success": true, "leadId": "k57a3bx9r2q8m1n0c4d6e8f0", "duplicate": false }Every time after that, it returns the same lead and flags the repeat:
{ "success": true, "leadId": "k57a3bx9r2q8m1n0c4d6e8f0", "duplicate": true }Both are a 200, so your form shows its thank-you message either way. Emails, webhooks, HubSpot sync and forwarding are scheduled in the same step that stores the lead, so a repeat can’t trigger them, and a crash between storing and notifying can’t lose them.
The rules 1DB applies
- The ID is 8 to 128 characters of letters, digits and
. _ : -. A UUID fits. - It’s required on contract v2 requests; a request without one gets a 400 naming
submission_id. - IDs are counted per site key. The same ID sent with another site’s key is a different lead.
- Requests without the v2 header are never deduplicated, so an older form that happens to send a fixed value can’t lose leads.
Retrying safely
Because repeats are harmless, your server route can retry without thinking about it:
429 Too Many Requests: wait the number of seconds inRetry-After, then send the same body.500or a network error: back off for a second or two and send the same body.400: don’t retry. Show the field errors to the visitor.
async function sendLead(body: object, attempts = 3): Promise<Response> {
for (let i = 1; ; i++) {
const res = await fetch(`${process.env.DB_API_ENDPOINT}/v1/leads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.DB_API_KEY!,
"X-1DB-Contract": "2",
},
body: JSON.stringify(body),
});
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || i === attempts) return res;
const wait = Number(res.headers.get("Retry-After")) || i;
await new Promise((r) => setTimeout(r, wait * 1000));
}
}The body, submission_id included, is the same on every attempt; that’s what makes the loop safe.
For the full form around this, see the Next.js contact form guide.