send.webforms.toRead the docs →

how-tohttpengineering

Confirm Form Resubmission: why browsers ask, and how to make it stop

The prompt is the browser stopping your visitor from sending the same order twice. The fix belongs on the server, and it comes down to one status code.

WebForms 6 min read

Someone fills in a form, presses submit, lands on a thank-you page and presses reload. Chrome answers with a dialog titled Confirm Form Resubmission: "The page that you're looking for used information that you entered. Returning to that page might cause any action you took to be repeated. Do you want to continue?" Press Back and then Forward instead, and Chrome may show a full error page with the code ERR_CACHE_MISS. Firefox asks the same question in its own words ("To display this page, Firefox must send information that will repeat any action (such as a search or order confirmation) that was performed earlier.") with Resend and Cancel buttons.

None of this is a bug in the browser, and nothing your visitor can change in their settings is the right fix. The page on their screen was built by your server, and the server built it the one way that forces the question.

Why the browser asks

A form with method="POST" sends a POST request, and the page that comes back is the response to that request. As far as the browser is concerned, the thank-you page and the act of submitting are the same history entry. Reloading means repeating the request, and HTTP makes no promise that repeating a POST is harmless: RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe, and PUT, DELETE and the safe methods as idempotent, and POST is in neither list. The same RFC says a user agent should make the user aware of an unsafe action before it is requested. A repeated POST might place a second order, charge a card twice or send a second enquiry, so the browser refuses to repeat it silently and asks.

ERR_CACHE_MISS is the history version of the same thing. Chromium defines it simply as "The cache does not have the requested entry", and its error page explains the specific case: you navigated back or forward to a page that was created by a POST and is no longer cached, so showing it again means sending the data again. Chrome's error page offers to do that if you press reload. Firefox calls its equivalent Document Expired.

The fix: Post/Redirect/Get

Never answer a successful form POST with a page. Answer it with a redirect to a page, and let the browser fetch that page with GET. The pattern is usually called Post/Redirect/Get, and it has three steps:

  1. The browser POSTs the form. The server does the work: stores the message, sends the email, places the order.
  2. The server responds 303 See Other, with a Location header pointing at a confirmation URL.
  3. The browser requests that URL with GET and shows it. That GET is now the history entry.

Reload now repeats the GET, which is harmless, so there is no prompt. Back goes to the form, Forward comes back to the confirmation, and neither touches the POST again. MDN's guide to redirections puts it the same way: after a 303, pressing reload redisplays only the confirmation page, without replaying the unsafe request.

Why 303, not 302

Three redirect codes come up, and what separates them here is what the browser does with the method. 303 See Other exists for this case: RFC 9110 describes it as primarily used to let the output of a POST redirect the user agent to a different resource, fetched with GET. 302 Found works in practice because browsers change POST to GET when they follow it, but the RFC says they may do that "for historical reasons", not that they must. 307 Temporary Redirect forbids changing the method, so the browser would POST the form data again to the new URL, which is the opposite of what you wanted. Use 303 and you are relying on the definition rather than a habit.

PHP

header() sets the Location, and its third argument sets the status code. Left out, PHP sends a Location header with a 302. Call it before any output, including a stray blank line before the opening tag, or it will not work at all.

contact.php
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    save_enquiry($_POST);   // your own validation and storage

    header('Location: /contact/thanks', true, 303);
    exit;                   // nothing may run after the redirect
}
?>
<form method="POST" action="/contact.php">
  <!-- fields -->
</form>

Express

res.redirect() defaults to 302. Pass the status first to send a 303:

server.js
app.post("/contact", express.urlencoded({ extended: false }), async (req, res) => {
  await saveEnquiry(req.body);
  res.redirect(303, "/contact/thanks");
});

Next.js route handler

In the App Router, a route.ts file that exports POST receives the form, and request.formData() reads it. Return Response.redirect() with an explicit 303. Be careful with redirect() from next/navigation here: inside a route handler it answers 307, which keeps the method as POST. In a Server Action it is different, and Next.js documents that redirect() serves a 303 for a form submitted without JavaScript.

app/contact/route.ts
export async function POST(request: Request) {
  const form = await request.formData();
  await saveEnquiry(Object.fromEntries(form));

  return Response.redirect(new URL("/contact/thanks", request.url), 303);
}

A redirect does not stop double submits

Post/Redirect/Get stops the reload prompt. It does nothing about a visitor who clicks submit twice while a slow request is in flight, or who confirms the resubmission dialog anyway on a page that still has it. Those need their own defences, one in the browser and one on the server.

In the browser, ignore a second submit while the first is under way. A flag is safer than disabling the button inside the submit handler: the HTML standard builds the request after the submit event and skips disabled controls, so a disabled submit button silently drops its own name and value from the data. Reset the flag when the page is shown again, because a page restored from the back-forward cache keeps its JavaScript state:

contact.js
const form = document.getElementById("contact");

form.addEventListener("submit", (event) => {
  if (form.dataset.sending) {
    event.preventDefault(); // the first submit is still in flight
    return;
  }
  form.dataset.sending = "1";
});

window.addEventListener("pageshow", () => {
  delete form.dataset.sending;
});

On the server, give each rendering of the form a unique token in a hidden field, record tokens you have already processed, and treat a repeat as already handled rather than as a new submission. For requests sent from your own JavaScript, the same idea travels in a header, an idempotency key, and that is where WebForms comes in.

What the WebForms endpoint returns

Our ingest endpoint is a POST target like any other, so everything above applies to it, and it is worth being exact about what it sends back.

  • With a redirect set (a _redirect or _next field in the form, or a redirect URL saved on the form in the dashboard), a successful browser submission gets 303 See Other pointing at your page. Reloading your thank-you page reloads your page, and nothing is sent again.
  • Without one, the browser receives our built-in thank-you page as the direct response to the POST. That is the one case where a visitor who reloads it sees the resubmission prompt, and if they confirm, the submission is sent again, then stored and delivered twice. If that matters for your form, set a redirect.
  • On a validation failure the visitor is sent back with a 303 to your _redirect URL, with wf_error=validation_failed added to the query string so your own page can show the error. _next and a saved redirect URL apply to success only; without _redirect, the error goes back to whatever page the browser named as the referrer, which for a cross-origin form is often just your home page.
  • A request that asks for JSON (an Accept: application/json header or a JSON body) and carries no _redirect gets JSON back, so a form submitted with fetch never navigates and never meets the dialog.

Duplicates are recognised only through the Idempotency-Key header. A repeat with the same key, to the same form, within a short window, is answered with success and duplicate: true, and nothing is stored twice. There is no matching on content, so with a plain HTML form, which cannot set request headers, a confirmed resubmission arrives as a second submission, and so can a double click; each one is stored, delivered and counted toward your monthly quota. If duplicates would hurt, submit with fetch and send a key that stays the same across retries of one message:

contact.js
const form = document.getElementById("contact");
let key = crypto.randomUUID(); // one key per message, reused on retry

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const res = await fetch(form.action, {
    method: "POST",
    headers: { Accept: "application/json", "Idempotency-Key": key },
    body: new FormData(form),
  });
  const result = await res.json();
  if (result.ok) {
    form.reset();
    key = crypto.randomUUID(); // the next message gets a fresh key
  }
});

Generate the key when the form is shown, not when it is sent: a key minted inside the submit handler is new on every attempt, which defeats the point. And if all you want is for the prompt to go away, you do not need any of the JavaScript. Add one hidden field and the browser is never left holding a POST response. Leave it out of a form you submit with fetch, though: with _redirect present, the endpoint redirects instead of answering with JSON.

contact.html
<input type="hidden" name="_redirect" value="https://yoursite.com/contact/thanks">

Get your endpoint

Free for 300 submissions a month. No card required.

Start free