Why forms get submitted twice
A duplicate submission almost always traces back to one of four moments, and none of them require a malicious visitor — careful users trigger all four by accident.
- Double-click. A trackpad double-tap or an impatient second click fires two POST requests before either response comes back.
- A slow response with no pending state. The button looks identical before and after the click. If the server takes a couple of seconds and nothing on screen changes, a visitor assumes the first click didn't register and clicks again — the most common cause on real traffic, and a pure UX bug, since the first click worked fine.
- Back button after a successful submit. The visitor goes back to re-read their message, then presses Send again because the form still holds their text and looks unsubmitted.
- Refresh on the confirmation page. If the page a visitor lands on is itself the direct response to the POST, refreshing repeats it — the "Confirm Form Resubmission" dialog. That has its own deep dive: see the full breakdown of the resubmission dialog for the browser mechanics and status-code details.
The three layers below map onto these causes: disabling the button removes the first two, Post/Redirect/Get removes the last two, and server-side dedupe backstops whatever still gets through.
Layer 1: disable the submit button and show a pending state
This single change removes the two most common causes of duplicates — the double-click and the confused second click during a slow response. Here's a small vanilla JavaScript snippet to prevent multiple form submissions: the moment submission starts, the button disables and its label changes, so there's never a window for a second click.
<form id="contactForm" action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
const form = document.getElementById("contactForm");
let isSubmitting = false;
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (isSubmitting) return; // guard: ignore a re-entrant submit
isSubmitting = true;
const btn = form.querySelector("button[type=submit]");
const label = btn.textContent;
btn.disabled = true;
btn.textContent = "Sending...";
try {
const res = await fetch(form.action, { method: "POST", body: new FormData(form) });
if (!res.ok) throw new Error("Server returned " + res.status);
btn.textContent = "Sent";
} catch (err) {
console.error(err);
btn.textContent = label;
btn.disabled = false;
isSubmitting = false; // only re-arm after a real failure
}
});
</script>isSubmitting and btn.disabled do the same job twice on purpose: the flag guards against a submit event re-firing before disabled visibly takes effect. On success the button stays disabled; on failure it resets so the visitor can retry.
The disabled-too-early trap. Put this logic in the form's submit handler, not a click listener on the button. Click fires before the browser's native constraint validation (required, type="email", pattern) has decided whether the submission proceeds. Disable on click, and a validation failure leaves the button stuck disabled with nothing sent. The submit event only fires after validation passes, so disabling inside it never touches a blocked attempt.
React: useState or useFormStatus. Outside a form action, a pending boolean in useState does the same job — true at the start of the submit handler, false only in a finally block, so a thrown error can't leave the button stuck. With React 19 form actions, useFormStatus reads pending state from the parent form automatically:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>;
}Layer 2: Post/Redirect/Get so refresh and back can't repost
Layer 1 only protects the window while the visitor is still looking at the form. Once a submission has completed, a disabled button in memory doesn't matter — refreshing the page, or navigating back and forward, can resend the exact same request. The fix is Post/Redirect/Get: the server processes the POST, then answers with a redirect instead of a page, so the page that actually lands in browser history is a harmless GET.
On a hosted endpoint, this is a single hidden field. Add redirect with the URL you want the visitor to land on, and splitforms issues the redirect after it stores the submission and sends the notification email:
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="redirect" value="https://yoursite.com/thank-you" />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>No JavaScript required — the redirect happens on the backend's side, so it works even with scripting disabled. The full field reference, including how splitforms responds when you submit with fetch() instead of a native POST, is in the docs. For the complete picture — every redirect method and the 303-vs-302-vs-307 status code details — see how to redirect after form submission.
Between Layer 1 and Layer 2, all four causes from the first section are covered. What's left is smaller and structurally different.
Layer 3: the server-side reality of true idempotency
What survives the first two layers is narrower: a network retry after a timeout, a visitor scripting the endpoint directly, or two browser tabs submitting the same form. None of these are stopped by a disabled button or a redirect — the client-side state Layer 1 relies on no longer exists. Solving it for real means the server recognizes a repeat and refuses to act on it twice.
Three patterns cover most self-hosted backends:
- Idempotency key. The client generates a random token once, when the form renders, and sends it with the request. The server records which keys it has already processed and returns the original result for a repeat instead of reprocessing — the pattern Stripe popularized for payments, where a duplicate costs real money.
- Unique constraint on a request id. Simpler to bolt onto an existing table: a unique column rejects the second insert at the database level. Catch the constraint-violation error and return the first record instead of a 500.
- A short dedupe window on email + payload hash. No client cooperation needed: hash the sender's email plus the message body, and treat a repeat of that hash within a short window (30-60 seconds) as a duplicate. It trades a small false-positive risk — two people coincidentally sending identical text back to back — for requiring no change to the form itself.
// Generic dedupe on a self-hosted endpoint (not a splitforms feature)
app.post("/api/contact", async (req, res) => {
const key = req.body.idempotency_key;
const existing = await db.submissions.findOne({ idempotency_key: key });
if (existing) return res.status(200).json(existing); // don't reprocess
const saved = await db.submissions.insertOne({ ...req.body, idempotency_key: key });
await sendEmail(saved);
res.status(200).json(saved);
});This layer is for a backend you run yourself — the right call for an order or payment, not a default every contact form needs.
Here's the honest part: most contact forms never need any of this. Once the button disables on submit and the endpoint uses Post/Redirect/Get, the residual duplicate rate is already close to zero, and idempotency machinery is real work to add for a low-volume form. splitforms doesn't expose an idempotency-key API — by default it ships honeypot filtering, a time-trap, and rate limiting, which absorb the case that actually produces duplicate floods: a bot or script retrying the same POST. Any duplicate that lands is visible in the dashboard and one click to delete — the pragmatic stopping point for most teams.
How to test your fix
Each layer has a specific test, and none of them require waiting for real traffic to find out whether it worked.
- Double-click test. Click Send as fast as you can, twice. Exactly one entry should land in your dashboard or inbox. Two means Layer 1 isn't wired correctly; zero — the button did nothing at all, not even once — is a different, unrelated bug, usually a markup issue; see HTML form not submitting.
- Slow-3G throttle. In DevTools, set Network throttling to Slow 3G and submit. The button should show its pending label for the entire wait, not flash briefly before a fast localhost server responds — that's the case real visitors on real networks actually hit.
- Refresh after submit. Submit, let the confirmation load, then refresh (Cmd/Ctrl+R). No browser resubmission dialog, no second email, no second dashboard entry. If you see the dialog, Layer 2's redirect isn't reaching that page.
Run all three on the deployed site, not just localhost, where caching and latency behave differently. They're three checks out of a longer list — the full pre-launch contact form checklist covers validation, spam defenses, deliverability, and accessibility too.
FAQ
Why does my form get submitted twice from one click?
Almost never from a single click — it's two requests landing close together. The two common causes are a double-click before the page visibly reacts, and a slow server response with no pending state, so the visitor assumes the first click did nothing and tries again. Disabling the button and showing a pending label the instant submission starts removes both.
How do I disable a submit button in JavaScript without breaking validation?
Put the disable logic inside the form's submit event listener, not a click listener on the button. Native constraint validation (required, type="email", pattern) runs before submit fires, so disabling on click can leave the button stuck after a validation failure with nothing actually sent. Disabling inside submit only ever locks the button on an attempt that already passed.
Does Post/Redirect/Get stop double-click duplicates?
No. PRG replaces the POST in browser history with a GET, so refresh and back/forward navigation can't resend the form — but it does nothing for a double-click, where two requests fire before either response returns. You need disable-on-submit for double-clicks and PRG for refresh and back-button repeats.
What is an idempotency key, and does a contact form need one?
A token the client generates once and sends with the request; the server records processed tokens and returns the original result for a repeat instead of reprocessing. Stripe popularized this for payments. Most contact forms don't need one — once the button disables on submit and the endpoint uses Post/Redirect/Get, the residual duplicate rate is already close to zero.
Does splitforms deduplicate submissions for me?
Not with an idempotency-key API — it doesn't expose one. By default it ships honeypot filtering, a time-trap, and rate limiting, which absorb the case that actually produces duplicate floods: a bot or script retrying the same POST. Any duplicate that lands is visible in the dashboard and one click to delete — the pragmatic stopping point for most contact forms.
How do I test that my duplicate-submission fix actually works?
Double-click Send as fast as you can and confirm exactly one entry lands. Throttle DevTools to Slow 3G and submit, watching the button show a pending label for the whole wait. Then submit once and refresh the confirmation page — no resubmission dialog, no second email, no second dashboard entry.
Want the redirect half of this handled for you? Get a free splitforms access key, point your form at the endpoint, add the hidden redirect field, and Post/Redirect/Get just works — free for 500 submissions a month.