How to test double form submission

Point a browser at a form whose endpoint is slow, double click the submit button, then count records at the server rather than reading the page. In one run, an unguarded form produced two orders in 7 of 10 attempts; a disable behind an await, 14 of 20.

Why check this

One button has four ways to send its body twice: a double click while the first request is open, an Enter key held down, the back button followed by a second submit, and a refresh on a page that a POST returned. Run it when a form gains a side effect that costs money or sends mail, and after any change to the submit handler.

The failure it prevents is one customer charged twice for one order, on a ticket nobody can reproduce. Count at the server: after a double submit the page looks correct either way.

Prerequisites

// server.mjs: one slow POST endpoint and three forms that differ only in their guard.
import { createServer } from 'node:http';

const PORT = 8934;
const WORK_MS = 1500;
let orders = [];

const GUARDS = {
  none: '',
  sync: `form.addEventListener('submit', () => { btn.disabled = true; });`,
  async: `form.addEventListener('submit', async (e) => {
    e.preventDefault();
    await fetch('/validate');
    btn.disabled = true;
    await fetch('/order', { method: 'POST', body: new FormData(form) });
    document.title = 'sent';
  });`,
};

const page = (guard) => `<!doctype html><html lang="en"><meta charset="utf-8"><title>order</title>
<form id="f" method="post" action="/order">
<input name="item" value="widget-9">
<button id="send" type="submit">Place order</button>
</form>
<script>
const form = document.querySelector('#f'), btn = document.querySelector('#send');
${GUARDS[guard] ?? ''}
</script>`;

createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  if (req.method === 'POST' && url.pathname === '/order') {
    let body = '';
    req.on('data', (c) => { body += c; });
    req.on('end', () => {
      setTimeout(() => {
        orders.push({ n: orders.length + 1, at: Date.now() });
        res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
        res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>thanks</title><p id="ok">order ${orders.length} placed</p>`);
      }, WORK_MS);
    });
    return;
  }
  if (url.pathname === '/validate') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end('{"ok":true}');
    return;
  }
  if (url.pathname === '/count') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ orders: orders.length }));
    return;
  }
  if (url.pathname === '/reset') {
    orders = [];
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end('{"orders":0}');
    return;
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
  res.end(page(url.searchParams.get('guard') ?? 'none'));
}).listen(PORT, '127.0.0.1', () => console.log(`listening on 127.0.0.1:${PORT}, ${WORK_MS} ms per order`));
// drive.mjs <case> [repeats] : counts orders at the server, not at the page.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const BASE = 'http://127.0.0.1:8934';
const [kase, repeats = '10'] = process.argv.slice(2);

const reset = () => fetch(`${BASE}/reset`).then((r) => r.json());
const count = () => fetch(`${BASE}/count`).then((r) => r.json()).then((j) => j.orders);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const browser = await launch({ executablePath: CHROME, headless: true });

async function trial(fn, guard = 'none') {
  const page = await browser.newPage();
  const posts = [];
  page.on('request', (r) => { if (r.method() === 'POST') posts.push(r.url()); });
  await reset();
  await page.goto(`${BASE}/?guard=${guard}`, { waitUntil: 'load' });
  const out = await fn(page);
  await sleep(2500);
  const n = await count();
  await page.close();
  return { orders: n, posts: posts.length, ...out };
}

const doubleClick = async (page) => {
  const box = await (await page.$('#send')).boundingBox();
  const x = box.x + box.width / 2, y = box.y + box.height / 2;
  const t0 = Date.now();
  await page.mouse.click(x, y);
  await page.mouse.click(x, y);
  return { gapMs: Date.now() - t0 };
};

const enterRepeat = async (page) => {
  const cdp = await page.createCDPSession();
  const key = { type: 'keyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r' };
  await page.focus('input[name=item]');
  await cdp.send('Input.dispatchKeyEvent', key);            // the press
  await sleep(500);                                          // Windows repeat delay
  let repeats = 0;
  for (let i = 0; i < 20; i += 1) { await cdp.send('Input.dispatchKeyEvent', { ...key, autoRepeat: true }); repeats += 1; await sleep(32); }
  await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' });
  return { repeats };
};

const backThenSubmit = async (page) => {
  await page.click('#send');
  await page.waitForSelector('#ok');
  await page.goBack({ waitUntil: 'load' });
  const disabled = await page.$eval('#send', (b) => b.disabled);
  await page.click('#send');
  await page.waitForSelector('#ok');
  return { buttonDisabledAfterBack: disabled };
};

const cases = {
  none: doubleClick, sync: doubleClick, async: doubleClick,
  enter: enterRepeat, 'enter-sync': enterRepeat,
  back: backThenSubmit, 'back-sync': backThenSubmit,
};
const guardOf = {
  none: 'none', sync: 'sync', async: 'async',
  enter: 'none', 'enter-sync': 'sync', back: 'none', 'back-sync': 'sync',
};

const runs = [];
for (let i = 0; i < Number(repeats); i += 1) runs.push(await trial(cases[kase], guardOf[kase]));
const orders = runs.map((r) => r.orders);
const more = runs.map((r) => r.orders > 1).filter(Boolean).length;
console.log(`case ${kase} (guard ${guardOf[kase]}), ${runs.length} attempt(s)`);
console.log(`orders per attempt: ${orders.join(' ')}`);
console.log(`more than one order: ${more} of ${runs.length}`);
for (const [k, v] of Object.entries(runs[0])) if (k !== 'orders') console.log(`first attempt ${k}: ${v}`);
console.log(`chrome ${await browser.version()}`);
await browser.close();
// reload-probe.mjs : does the reload of a POST result page prompt, or just re-POST?
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const BASE = 'http://127.0.0.1:8934';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const count = () => fetch(`${BASE}/count`).then((r) => r.json()).then((j) => j.orders);

const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
page.on('dialog', async (d) => { console.log(`dialog ${d.type()}: ${d.message()}`); await d.accept(); });

for (const how of ['cdp-reload', 'f5-key', 'location-reload']) {
  await fetch(`${BASE}/reset`);
  await page.goto(`${BASE}/`, { waitUntil: 'load' });
  await page.click('#send');
  await page.waitForSelector('#ok');
  const before = await count();
  if (how === 'cdp-reload') await page.reload({ waitUntil: 'load' });
  if (how === 'f5-key') {
    const cdp = await page.createCDPSession();
    await cdp.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', windowsVirtualKeyCode: 116, key: 'F5', code: 'F5' });
    await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 116, key: 'F5', code: 'F5' });
  }
  if (how === 'location-reload') await page.evaluate(() => location.reload());
  await sleep(3000);
  const after = await count();
  const state = await page.evaluate(() => ({ url: location.pathname, title: document.title, body: document.body.innerText.trim().slice(0, 90) }));
  console.log(`headless ${how}: orders ${before} -> ${after}, url ${state.url}, title "${state.title}", body "${state.body}"`);
}
console.log(`chrome ${await browser.version()}`);
await browser.close();

Steps

  1. Step 1.

    Start the target and leave it running in its own shell.

    node server.mjs
    
    listening on 127.0.0.1:8934, 1500 ms per order

    Every count below comes from /count on this process, not from the page.

  2. Step 2.

    Double click the submit button of an unguarded form, ten times.

    node drive.mjs none 10
    
    case none (guard none), 10 attempt(s)
    orders per attempt: 1 2 2 2 2 2 2 1 1 2
    more than one order: 7 of 10
    first attempt posts: 1
    first attempt gapMs: 28
    chrome Chrome/152.0.7977.76

    Seven attempts of ten created two orders. An earlier run of the same command gave 10 of 10, so one run is not the answer.

  3. Step 3.

    Repeat against the form that sets btn.disabled = true inside the submit handler.

    node drive.mjs sync 10
    
    case sync (guard sync), 10 attempt(s)
    orders per attempt: 1 1 1 1 1 1 1 1 1 1
    more than one order: 0 of 10
    first attempt posts: 1
    first attempt gapMs: 83
    chrome Chrome/152.0.7977.76

    One order every time. A check that stops here signs the form off.

  4. Step 4.

    Move the same disable behind one await, as a handler that validates before sending does.

    node drive.mjs async 20
    
    case async (guard async), 20 attempt(s)
    orders per attempt: 1 2 2 2 2 2 2 2 1 2 1 1 2 2 2 1 2 1 2 2
    more than one order: 14 of 20
    first attempt posts: 1
    first attempt gapMs: 55
    chrome Chrome/152.0.7977.76

    The button is still disabled on submit. One awaited call before it opened a gap the second click fits through, on 14 attempts of 20.

  5. Step 5.

    Hold Enter in the text field: one press, then twenty key repeats at 32 ms.

    node drive.mjs enter 5
    
    case enter (guard none), 5 attempt(s)
    orders per attempt: 21 21 21 21 21
    more than one order: 5 of 5
    first attempt posts: 21
    first attempt repeats: 20
    chrome Chrome/152.0.7977.76

    Twenty one orders per attempt, one per keydown, while a navigation was already in flight. node drive.mjs enter-sync 5 against the guarded form gave one order in all five.

  6. Step 6.

    Submit, go back, then submit the restored form again, under the guard that held in step 3.

    node drive.mjs back-sync 3
    
    case back-sync (guard sync), 3 attempt(s)
    orders per attempt: 2 2 2
    more than one order: 3 of 3
    first attempt posts: 2
    first attempt buttonDisabledAfterBack: false
    chrome Chrome/152.0.7977.76

    buttonDisabledAfterBack: false is the mechanism. Going back loaded the form again and the flag went with it.

  7. Step 7.

    Reload the page the POST returned, three ways.

    node reload-probe.mjs
    
    headless cdp-reload: orders 1 -> 2, url /order, title "thanks", body "order 2 placed"
    headless f5-key: orders 1 -> 1, url /order, title "thanks", body "order 1 placed"
    headless location-reload: orders 1 -> 2, url /order, title "thanks", body "order 2 placed"
    chrome Chrome/152.0.7977.76

    Two of the three reloads re-sent the body and created a second order, with no dialog and no interstitial. The F5 keydown changed nothing: that shortcut belongs to the browser window, not to the document. This harness cannot press the browser's own reload control, so nothing here says what a person clicking it sees.

  8. Step 8.

    Send the same body twice from a client that never loaded the page.

    ( curl -s http://127.0.0.1:8934/reset; echo; for i in 1 2; do curl -s -o /dev/null -w "%{http_code} " -d item=widget-9 http://127.0.0.1:8934/order & done; wait; echo; sleep 2; curl -s http://127.0.0.1:8934/count; echo ) 2>/dev/null
    
    {"orders":0}
    200 200
    {"orders":2}

    Two 200s and two orders. Every guard in steps 3 to 6 lives in a script this client never ran.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Two orders from one double click | Both clicks reached the endpoint before the first response came back | Move the guard to the server. How to check a background job is idempotent has the mechanism. | | One order in every attempt with the button disabled on submit | The synchronous guard holds against a click in this browser | Keep it, and carry on to steps 6 to 8 before calling the form safe. | | A count that differs between two runs of one command | The gap between the request and the guard is a race | Report the count out of the attempts. One run is a coin toss. | | Twenty one orders from one keypress | Implicit submission fires per keydown, including OS key repeats | Guard the endpoint. No key handler covers a stuck key on every input. | | Two orders after the back button, buttonDisabledAfterBack: false | A fresh load of the form reset the client flag | Stop relying on in-page state surviving navigation. | | A reload that lands on the POST URL with the count one higher | The body was re-sent with nothing asked | Answer the POST with a 303 to a GET result page. | | Two 200s from two posts made outside the browser | A client without your script reaches the same endpoint | The only guard that covers this is at the server. |

Common mistakes

Sign: The double submit check passes in CI and duplicate orders keep arriving in production.Cause: The check stops at the click. Disabling the button in the submit handler held for 10 of 10 double clicks here, and did nothing in step 6, step 7 or step 8, where the second request comes from a reloaded form, a reload, or a client that never ran the script.
Sign: The same test reports a defect on some runs and passes on others, so it gets marked flaky and skipped.Cause: Moving the disable behind one awaited call opened a window of a few milliseconds. Twenty attempts produced a second order 14 times. The test is not flaky, the defect is intermittent, and the number of attempts belongs in the report next to the count.
Sign: The reload step is written around the Confirm Form Resubmission prompt and never fires.Cause: No dialog and no interstitial appeared on any reload measured here. A protocol reload and location.reload() each re-sent the POST silently, and an F5 key event dispatched into the page did nothing, because that shortcut is handled by the browser window and not by the document.
Sign: The page after the second submit looks correct, so the form is signed off.Cause: The second response replaces the first and both say the order was placed. Only the count at the endpoint separates them: the body read order 2 placed while the tester was looking at a page that had said order 1 placed a second earlier.

What to check next

FAQ

How do you prevent double form submission?

At the server. Derive a key from the submission, store it under a unique constraint, and write it before the side effect. Client guards lower the rate and none removed it here.

Does disabling the submit button after a click fix it?

It held for every double click in step 3 and every Enter repeat against the guarded form in step 5, and did nothing in steps 6, 7 and 8. Treat it as a way to stop an accidental click, not as the control on uniqueness.

What is confirm form resubmission?

Chrome's warning before re-sending a POST body on a reload. No run in step 7 produced one: two of three reloads re-sent the body silently and created a second order. A test written around that dialog asserts on what this harness never sees.

How do you prevent form resubmission on refresh?

Answer the POST with a 303 redirect to a result page fetched by GET, so a reload repeats the GET. Step 7 shows the other shape: the URL stayed on the POST target and the count moved from 1 to 2.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0curl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny