splitforms.com
All articles/ GUIDES9 MIN READPublished June 12, 2026

Confirm Form Resubmission on Refresh: The POST/Redirect/GET Fix (2026)

How to stop the 'Confirm Form Resubmission' browser popup: why POST responses break refresh, the POST/Redirect/GET pattern with HTTP 303 details, duplicate-submission protection, and the fix for static sites.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

What the popup is actually telling you

When a form submits the old-fashioned way — a real navigation, no JavaScript interception — the browser sends a POST and renders whatever HTML comes back. That rendered page now has an awkward property: its address in history is "the result of POSTing this body to this URL."

Hit refresh, and the browser faces a question it can't answer alone. Re-rendering the page means re-sending the POST, and POST is — by the HTTP spec — a non-idempotent method: doing it twice may have twice the effect. A second order. A second payment. A second email in your inbox. So the browser punts to the user:

Chrome:  "Confirm Form Resubmission — The page that you're looking
          for used information that you entered..." (ERR_CACHE_MISS)
Firefox: "To display this page, Firefox must send information that
          will repeat any action..."
Safari:  "Are you sure you want to send a form again?"

Visitors read this as "the site is broken," click through, and generate the duplicate you were supposed to prevent — or bail entirely. The dialog isn't a browser quirk to suppress; it's a symptom that your server answers POST with a page instead of a redirect.

Why POST responses make terrible pages

Beyond the refresh dialog, a POST-rendered success page is broken in four quieter ways:

  • It's unbookmarkable and unshareable — the URL alone can't reproduce it.
  • Back/forward navigation mines the dialog — going back past it and forward again re-raises the prompt.
  • It can't be cached — every render requires re-executing the side effect.
  • Analytics and ads misfire — conversion pixels on a page that re-triggers on refresh double-count, which is how marketing dashboards end up showing more "leads" than the inbox has emails.

HTTP's method semantics draw the line: GET is safe and repeatable, POST is neither. A page a human might refresh, bookmark, or revisit should always live behind GET. The fix that enforces this is fifty years old in spirit and three lines in practice.

The POST/Redirect/GET pattern, step by step

PRG splits "process the submission" from "show the result" into two requests:

1. Browser:  POST /contact          (form data in the body)
2. Server:   ...saves submission, sends notification email...
             HTTP/1.1 303 See Other
             Location: /thanks
3. Browser:  GET /thanks             (follows the redirect)
4. Server:   200 OK + thank-you HTML

The page the visitor ends up on — and the page that enters history — is the GET in step 3. Refresh re-runs step 3 only: a cheap, safe, idempotent fetch of the thank-you page. The POST exists in no one's history. The dialog is structurally impossible.

Why 303 specifically

Redirect status codes differ in what method the browser uses at the new URL:

  • 303 See Other — "fetch the result with GET." Designed for exactly this. Use it.
  • 302 Found — ambiguous by spec; browsers switch to GET by convention, so it works, but 303 says what you mean.
  • 307/308 — method-preserving: the browser re-POSTs the body to the new URL. Using 307 here recreates the resubmission problem at a new address.
  • 301 — permanent and cacheable; wrong semantics for a per-submission hop.

Implementation sketches

# PHP
header("Location: /thanks", true, 303);
exit;

# Express
app.post("/contact", async (req, res) => {
  await save(req.body);
  res.redirect(303, "/thanks");
});

# Next.js server action — redirect() responds with 303
"use server";
export async function submit(formData: FormData) {
  await save(formData);
  redirect("/thanks");
}

One refinement: the thank-you page can't see the POST data anymore (it's a fresh GET), so pass display state via the session, a one-time "flash" message, or a query param (/thanks?ref=abc123) — never by re-reading the POST body.

AJAX forms: no popup, new responsibilities

If JavaScript intercepts the submit and sends the data with fetch(), the browser never navigates — the visible page remains the original GET, and refreshing it can't resubmit anything. This is why most modern forms never show the dialog.

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const btn = form.querySelector("[type=submit]");
  btn.disabled = true;                       // duplicate-click guard
  try {
    const res = await fetch(form.action, { method: "POST", body: new FormData(form) });
    if (!res.ok) throw new Error(await res.text());
    showSuccess();                            // or location.assign("/thanks")
  } catch (err) {
    showError(err);
    btn.disabled = false;                     // let them retry on failure
  }
});

But notice what you inherited: the dialog was the browser's duplicate-prevention, and you just opted out of it. Now you must disable the button while the request is in flight, handle the failure path (a swallowed error here is the classic form-not-submitting bug), and decide what a retry means. For anything with money attached, add an idempotency token: a hidden random value generated at render time that the server accepts only once.

Static sites: let the endpoint do the redirecting

PRG requires a server to emit the 303 — which a static host doesn't have (POST to a static page doesn't even get that far; it dies with a 405). The pattern still applies; it just runs on the form endpoint instead of your host.

A hosted form backend like splitforms implements the whole flow: your plain HTML form posts cross-origin to the endpoint, the endpoint stores the submission, fires the notification email, and answers the browser with a 302 redirect to your thank-you URL — declared with a hidden redirect field:

<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/thanks" />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

The visitor lands on a refresh-safe GET page, you get the email plus a dashboard record, and spam is filtered server-side — no functions, no backend, works identically on GitHub Pages, Netlify, and Vercel. Free for 500 submissions/month; setup details in the docs and API reference, with framework guides for Next.js, React, and Astro.

The no-resubmission checklist

  1. Every form POST answers with a 303 to a GET-able success URL (or is sent via fetch with no navigation).
  2. The submit button disables on click and shows a pending state.
  3. The thank-you page renders correctly when loaded directly by URL — proof it's a real GET page.
  4. Refresh on the thank-you page: no dialog, no duplicate email, no double-counted conversion.
  5. Back then forward through the whole flow: no dialog at any step.
  6. For costly operations, the server enforces a one-time idempotency token.

Run this as part of the broader pre-launch form test checklist — refresh behavior is pass 1, step 5.

FAQ

What does 'Confirm Form Resubmission' actually mean?

It means the page currently displayed is the direct response to a POST request, and the browser cannot re-render it without sending the POST again. Refreshing means repeating the request that produced the page — so Chrome shows ERR_CACHE_MISS / 'Confirm Form Resubmission', Firefox asks to 'resend' — because repeating a POST may repeat its side effects: a duplicate order, a double payment, a second email. The dialog is the browser protecting your visitor from your server's response design, not a bug in the browser.

What is the POST/Redirect/GET (PRG) pattern?

PRG is the standard fix: the server handles the POST (saves the data, sends the email) and then, instead of returning HTML, returns a redirect — ideally 303 See Other — to a success URL. The browser follows it with a GET, and that GET response is what the visitor sees. Now the page in history is a harmless GET: refresh re-fetches the thank-you page, back/forward navigation works normally, and the URL is bookmarkable. Every well-built form on the web — checkouts, logins, CMSs — uses PRG.

Should I redirect with 301, 302, 303, or 307 after a form POST?

Use 303 See Other — it exists precisely for this case, instructing the browser to follow with GET regardless of the original method. 302 works in practice because browsers historically switch POST→GET on 302, but 303 states the intent unambiguously. Never 307 or 308: those preserve the method, so the browser would re-POST the body to the redirect target, recreating the exact problem. 301 is for permanent moves and gets cached aggressively — wrong tool. In modern frameworks, check the default: Express res.redirect() sends 302 (pass 303 explicitly), and Next.js redirect() in a server action issues a 303.

Does the resubmission popup happen with fetch/AJAX forms?

No. When JavaScript sends the POST via fetch or XMLHttpRequest, the browser never navigates — the displayed page is still the original GET, so refresh just reloads the page and no dialog appears. That's why most modern forms don't hit this. The dialog only appears with native form navigation (no preventDefault). But AJAX trades the problem rather than eliminating it: you now need your own duplicate-click protection, since the popup was at least warning users before duplicates happened.

How do I prevent duplicate submissions when users refresh or double-click?

Defense in depth, three layers. (1) PRG eliminates refresh-resubmits structurally. (2) Disable the submit button on first click and show a pending state — this kills double-click duplicates, the most common kind. (3) For payments or anything costly, add an idempotency key: a hidden random token generated when the form renders; the server records used tokens and ignores repeats. Stripe popularized this pattern with the Idempotency-Key header, and it's the only layer that survives network retries, impatient back-button users, and browser crashes.

Why does pressing the back button re-show my form's POST page or old data?

Two separate behaviors. If the result page was a POST response (no PRG), going back then forward can trigger the resubmission dialog from history. Separately, browsers restore form field values from the back-forward cache (bfcache) when you navigate back — that's a feature, not a bug, and it's why visitors don't lose half-typed messages. With PRG implemented, back from the thank-you page returns to the (GET) form page, possibly with restored field values, and no dialog appears anywhere in the cycle.

I'm on a static site with no server — how do I get PRG behavior?

You can't issue redirects without a server, but your form endpoint can. Hosted form backends implement PRG for you: splitforms accepts the POST at https://splitforms.com/api/submit and answers with a 302 redirect — to your own thank-you URL if you add a hidden redirect field, or to a built-in success page otherwise — so the visitor always lands on a refresh-safe GET page. Combined with built-in spam filtering, you get correct post-submit behavior on GitHub Pages, Netlify, or any static host without writing a line of server code.

A hosted form backend handles PRG for you — get a free splitforms access key and let the endpoint redirect for you.

Related: contact form debugging guide, the form action attribute, completely, and the splitforms FAQ.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

500 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your dashboard. Starter adds inbox delivery.

Generate access key →Read the docs
founders pricing locked in · early access open