Skip to content
SheetLink Forms

Building · 2026-08-13 · 9 min read · By Arden Talbot, founder of SheetLink

Post/Redirect/Get: a biography of the web's quietest pattern

How a duplicate-order bug from the early web produced a three-step dance that now runs almost every form you submit - and why HTTP grew a whole status code for it.

A ledger-styled illustration of a form submission looping through post, redirect, and get stages before landing as a single clean row.

The duplicate order problem

Somewhere in the late 1990s, a pattern of customer complaint became common enough to get its own name: the double order. A shopper filled out a checkout form, pressed submit, waited on a slow connection, got nervous, and pressed refresh. The browser did exactly what it was told - it sent the same POST request again - and the shop charged the card twice. The same failure hit guestbooks, comment boards, and registration forms. Anywhere a form wrote something down, a refresh could write it down twice.

Browsers eventually added a warning dialog - the famous "Confirm Form Resubmission" prompt that still exists today - but a warning is not a fix. Users click through warnings. The real fix had to change what the browser remembered about the page it was on, and that fix became one of the most widely deployed patterns in web history: Post/Redirect/Get.

Why POST replays at all

The bug was not a browser defect. It was HTTP behaving as designed. HTTP divides requests into safe methods and unsafe ones. GET is safe: fetching a page is not supposed to change anything, so a browser can re-issue it freely - on refresh, on back, from cache. POST is the opposite. It exists precisely to change something on the server, and HTTP makes no promise that doing it twice is harmless.

The trouble is what sits in the browser's history after a form submission. If the server answers a POST with a normal 200 page - "Thanks for your order!" - then the current history entry is the POST. Refreshing that page means replaying the POST. The back button and the bookmark run into the same wall: the confirmation page has no address of its own, only a stored request with side effects. The early web shipped this arrangement everywhere, because returning HTML directly from the POST was the obvious thing to do.

The workarounds that came first

Before the pattern settled, developers attacked the symptom from every angle. JavaScript disabled the submit button after the first click. Pages carried stern warnings: "Do not press back or refresh while your order is processing." Server frameworks grew synchronizer tokens - a one-time value embedded in the form, checked and burned on submission, so a replayed POST could be recognized and rejected.

All of these still exist, and the token approach is genuinely useful for other reasons. But they treat the duplicate as something to catch rather than something to prevent. The insight that stuck was simpler: if the problem is a POST living in the browser's history, get the POST out of the history.

The pattern gets a name

The trick circulated in server-side circles through the early 2000s and was written up most influentially in 2004, when Michael Jouravlev published "Redirect After Post," arguing that a POST should never answer with a page at all. Instead: accept the POST, do the work, and respond with a redirect to a fresh URL. The browser follows the redirect with a GET, and that GET is what lands in history. Refresh re-runs a harmless GET. Back and bookmark behave. The confirmation page has a real address.

Three steps - Post, Redirect, Get - hence the name, now documented everywhere from framework guides to its own encyclopedia entry. It is a rare kind of pattern: no library required, no dependency, just a discipline about which status code you return.

Why 303 exists

Which status code, though? Here the history gets genuinely funny. HTTP/1.0 offered 302 Found for redirects, and the specification said a redirected request should keep its original method - a redirected POST should be re-POSTed to the new location. Nearly every browser ignored this and switched to GET instead, because that is what users and developers actually wanted.

HTTP/1.1 resolved the standoff by splitting 302 in two. 307 Temporary Redirect means what 302 was supposed to mean: repeat the same method at the new URL. And 303 See Other blesses what browsers were already doing: the response to your POST lives elsewhere, go GET it. 303 is, in effect, the PRG pattern promoted into the protocol itself - a status code that exists because a workaround became the standard. Today's HTTP specification, RFC 9110, still describes 303 in almost exactly those terms.

What PRG quietly fixed

The pattern's payoff is larger than duplicate prevention. With PRG, the thank-you page becomes an ordinary page: it can be refreshed, bookmarked, shared, and revisited without ceremony. Analytics can count it as a pageview, which is why conversion tracking conventionally fires on a thank-you page. The back button stops being a foot-gun. Browser history becomes a list of places rather than a list of actions.

There is also a subtler benefit: PRG separates the write from the read. The POST endpoint does one job - record the submission - and the GET endpoint does another - show a result. That separation is the same idempotency discipline that makes the rest of the web cacheable and resilient, applied at the humble scale of a contact form.

What PRG never fixed

Honesty requires the other column. PRG prevents the refresh duplicate, not every duplicate. A user who double-clicks submit fires two POSTs before any redirect arrives; a flaky network layer that retries a timed-out POST does the same. Preventing those still takes the older tools - disabled buttons, one-time tokens, or server-side deduplication.

PRG also creates a small problem of its own: the redirect throws away the request's context. If the confirmation page needs to say "Thanks, Maria, your ticket number is 481," that state has to survive the hop - via a session, a query parameter, or the "flash message" machinery most frameworks grew for exactly this purpose. The pattern is a trade: you give up easy state for safe history. Most of the time it is a good trade, but it is a trade.

PRG in the fetch era

Then JavaScript changed the terms. An AJAX submission never navigates, so nothing enters browser history and there is nothing for refresh to replay. The original problem PRG solved simply does not arise, and a redirect answer becomes an awkward fit - scripts generally want a small JSON acknowledgment they can render inline, not a page to follow.

So the modern convention is dual-mode: answer a plain HTML form post with a 303 redirect, and answer a JSON or AJAX request with a JSON body. A well-behaved form endpoint detects which kind of client is talking and replies in kind. Our AJAX form guide walks through the script side of this in detail.

How we implement it

SheetLink Forms follows the convention exactly. A plain HTML post to an endpoint gets a 303 See Other - to a hosted thank-you page by default, or to any custom redirect URL you configure, as covered in our redirect-after-submit guide. A JSON or AJAX request gets {"ok":true,"id":"..."} back instead. The submission itself lands as a row in your spreadsheet either way - the full path is on how it works.

The whole arrangement fits in one snippet:

<form action="https://sheetlinkforms.com/f/your-token" method="POST">
  <input type="email" name="email" required>
  <input name="_slhp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">
  <button type="submit">Send</button>
</form>

Submit that, and the 303 does the rest. You can watch it happen on the live demo.

Patterns outlive stacks

Post/Redirect/Get is now roughly a quarter-century old. In that time the server side of the web has been rewritten several times over - CGI to PHP to Rails to Node to serverless - and the pattern has crossed every rewrite intact, because it lives in the protocol rather than in any framework. That is worth noticing. The web's most durable engineering is often not a product or a library but an agreement about behavior: return this status code, and every browser ever shipped will do the right thing.

When you submit a form today and land gently on a page you can refresh without fear, you are watching a 1990s bug report, a 2004 essay, and an RFC's status code cooperate across decades. The docs cover our corner of that machinery; the rest belongs to the web.

FAQ

What does Post/Redirect/Get actually mean?

It is a server-side pattern for handling form submissions in three steps: accept the POST, respond with a redirect instead of a page, and let the browser GET the destination. The GET is what enters browser history, so refreshing never replays the form.

Why does refreshing after a form submit cause a duplicate?

If the server answers a POST with a normal page, the browser's current history entry is the POST itself. Refresh means "repeat the current request," so the browser re-sends the form data and the server processes it again.

What is the difference between 302 and 303?

302 was ambiguous: the spec said keep the original method, but browsers switched to GET anyway. HTTP/1.1 split it into 307, which preserves the method, and 303, which explicitly instructs the client to follow up with a GET - the code purpose-built for PRG.

Does PRG stop all duplicate submissions?

No. It stops the refresh and back-button replay, which was the common case. Double-clicks and network-level retries still send multiple POSTs before any redirect arrives, so belt-and-braces setups also disable the button or deduplicate server-side.

Is PRG still relevant for AJAX forms?

The problem it solves does not occur with AJAX, because fetch calls never enter browser history. The modern convention is dual-mode: 303 redirects for plain HTML posts, JSON responses for AJAX. SheetLink Forms endpoints do both automatically.

Where should the redirect send people?

A dedicated thank-you page is the usual answer - it confirms the submission, hosts your conversion tracking, and can be revisited safely. Our redirect guide covers custom redirect URLs too.

Who invented Post/Redirect/Get?

No single person - it emerged from practice in the early 2000s. Michael Jouravlev's 2004 article "Redirect After Post" is the write-up most often credited with codifying and popularizing it.

Does SheetLink Forms use a 303 redirect?

Yes. Plain HTML posts to an endpoint receive a 303 See Other to a thank-you page or your custom URL, while JSON and AJAX requests receive {"ok":true,"id":"..."}. You can see both on the live demo.

Forms that redirect properly, out of the box

Point a form at an endpoint and get the 303, the thank-you page, and a spreadsheet row - no server required.

Start freeSee the live demo

The error states nobody designsWhat actually happens to a form submission