Formclick

How to Prevent Duplicate Form Submissions

Double-clicks, refreshes and slow networks create duplicate submissions. Learn how to stop them in the browser, after redirects and on the server with idempotency.

FFormclick Team3 min read
An office employee using a photocopier to copy documents indoors.
Photo by Andrea Piacquadio on Pexels
On this page
  1. Why duplicates happen
  2. Layer 1: Disable the button while sending
  3. Layer 2: Post/Redirect/Get (PRG)
  4. Layer 3: Server-side duplicate detection
  5. What not to do
  6. Summary

You receive the same enquiry twice. A customer is charged twice. A sign-up list has the same person three times. Duplicate form submissions are annoying at best and expensive at worst — and they're almost always caused by ordinary user behaviour, not bugs.

Why duplicates happen

  • Double-clicking the submit button (very common, especially among people used to double-clicking desktop icons).
  • Impatience on a slow connection — nothing seems to happen, so they click again.
  • Refreshing the results page after a POST, which asks the browser to resend the form.
  • The back button followed by submitting again.
  • Network retries by the browser, a proxy or your own JavaScript.

The fix is layered: stop most duplicates in the browser, and catch the rest on the server.

Layer 1: Disable the button while sending

The simplest, most effective fix:

const form = document.querySelector('form');

form.addEventListener('submit', function (event) {
  if (form.dataset.submitting === 'true') {
    event.preventDefault(); // already sending — ignore the second click
    return;
  }
  form.dataset.submitting = 'true';
  const button = form.querySelector('[type="submit"]');
  button.disabled = true;
  button.textContent = 'Sending…';
});

The flag matters as well as the disabled button: pressing Enter in a text field can submit a form even when the button is disabled.

If you submit with fetch(), re-enable the button when the request finishes — and keep it disabled on success:

form.addEventListener('submit', async function (event) {
  event.preventDefault();
  if (form.dataset.submitting === 'true') return;
  form.dataset.submitting = 'true';
  button.disabled = true;

  try {
    const response = await fetch(form.action, {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.success) {
      form.reset();
      showMessage('Thanks! We got your message.');
      return; // leave the button disabled — the job is done
    }
    showMessage(result.message || 'Please try again.');
  } catch {
    showMessage('Network error. Please try again.');
  }
  // Only re-enable after a failure, so people can retry.
  form.dataset.submitting = 'false';
  button.disabled = false;
});

Show progress. A spinner or "Sending…" text stops most repeat clicks before they happen.

Layer 2: Post/Redirect/Get (PRG)

If a POST request returns a normal page, refreshing that page resubmits the form. The standard fix is Post/Redirect/Get: after handling the POST, the server responds with a redirect (HTTP 303) to a thank-you page. The browser loads that page with GET, so refreshing it is harmless.

With Formclick, you get PRG by adding a redirect field:

<input type="hidden" name="redirect" value="https://yourwebsite.com/thanks.html">

After submitting, the visitor lands on your thank-you page, and a refresh just reloads it.

Layer 3: Server-side duplicate detection

Browser fixes can't catch everything — JavaScript can fail, and bots don't use your button. The server should recognise repeats.

Content-based detection

Ignore a submission if it's identical to one received from the same source very recently. Formclick does this automatically: an identical submission to the same form from the same visitor within a couple of minutes is ignored, while the visitor still sees a normal success response. Combined with rate limiting, this also blunts simple flooding.

Idempotency keys (for payments and orders)

For actions where a duplicate costs money, use an idempotency key — a unique ID generated once per form load:

<input type="hidden" name="idempotency_key" value="">
<script>
  document.querySelector('[name="idempotency_key"]').value = crypto.randomUUID();
</script>

On the server, store keys you've processed. If a request arrives with a key you've already seen, return the original result instead of doing the work again:

const existing = await db.get('SELECT result FROM processed WHERE key = ?', key);
if (existing) return existing.result;          // duplicate: same answer, no new order
const result = await createOrder(data);
await db.run('INSERT INTO processed (key, result) VALUES (?, ?)', key, result);
return result;

Add a unique constraint on the key column so two simultaneous requests can't both slip through.

Natural uniqueness

Sometimes the data itself should be unique — one registration per email per event, one vote per person. Enforce it with a unique database constraint and show a friendly "You're already registered" message.

What not to do

  • Don't rely only on a disabled button. It doesn't stop refreshes, Enter-key submits or bots.
  • Don't silently drop retries after a failure. If the first attempt failed, the second must go through.
  • Don't show an error for a detected duplicate — the person did nothing wrong. Show success.

Summary

Cause Fix
Double-click, impatience Disable button + submitting flag + progress text
Refresh after submit Post/Redirect/Get
Repeats that slip through Server-side duplicate detection
Payments and orders Idempotency keys + unique constraints

Want the full pattern for JavaScript submissions? See How to Submit HTML Forms Using the Fetch API.

Found this useful? Share it.

All articles