FormsGENERALBROWSER-SPECIFICNETWORK-SPECIFIC

Submission: Method, Encoding and Doing It Once

GET versus POST is a semantic choice with cache and history consequences; FormData and enctype decide what goes on the wire; and preventing the second submit is your job.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What actually happens when this form is submitted, and how do I make sure it happens exactly once?

The user intent

Someone presses a button that costs money, sends a message or changes something they care about. They want to know it worked, and they very much do not want it to happen twice.

The obvious build

On button click, read the values from state, JSON.stringify them, fetch with POST, and navigate when it resolves. Method and encoding are details the API handles.

Why it breaks

Two rapid presses — a double-click, an impatient tap on a slow connection, an Enter press followed by a click — send two requests. Two orders exist, and the user sees one confirmation.

How it breaks in a real browser
  • Two rapid presses — a double-click, an impatient tap on a slow connection, an Enter press followed by a click — send two requests. Two orders exist, and the user sees one confirmation.
  • The file input sends nothing useful, because JSON has no representation for a file and the field serialised to a filename string (File Upload UX).
  • A search form using POST cannot be bookmarked, shared, or reloaded without a resubmission prompt, and the back button behaves in a way users read as broken (The URL Is Application State).
  • A form using GET for a destructive action gets executed by a prefetcher, a link scanner, or the browser's own preloading — nobody clicked, and the record is gone (GET: The Promise of Safety).
  • The request fails and the button remains in its loading state forever, because the error path was never written; the user's only remaining move is to reload and hope (Loading, Error, Empty — The States You Did Not Render).
  • A slow first request and a fast retry resolve out of order, so the UI renders the older response as the final state (Out-of-Order Responses).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A native submit builds an entry list from named, non-disabled controls owned by the form, then encodes and sends it according to method and enctype.
  • method="get" puts the encoded entry list in the query string and navigates. The result is a URL: bookmarkable, shareable, cacheable, re-runnable, and in browser history. It is the right method for anything that only reads.
  • method="post" puts the entry list in the request body and navigates. It is not cached, reloading prompts for resubmission, and it is the right method for anything that changes state (POST: More Than Create).
  • enctype decides the body format: application/x-www-form-urlencoded (the default, key/value pairs, no files), multipart/form-data (required for file uploads, each part with its own headers), and text/plain (which exists and should not be used).
  • new FormData(form) produces the same entry list as a construct you can inspect and send yourself. Passing it as a fetch body sets multipart/form-data with the correct boundary automatically — which is why manually setting Content-Type for a FormData body breaks the request.
  • e.preventDefault() in the submit handler stops the navigation only. Validation has already run and the entry list is still available; you are substituting the transport, not the algorithm (Native Forms First).
  • A submit-button value/name pair is included in the entry list, and formaction, formmethod, formenctype and formnovalidate on a button override the form's attributes — which is how one form supports "Save" and "Save draft" without JavaScript.

What this makes the browser do

And which of it is avoidable.

  • Constructing and encoding the entry list. For multipart with files, streaming the file contents from disk rather than loading them into memory.
  • For a native submit, unloading the current document and starting a navigation, which discards all page state including any JavaScript-held values (History and Navigation).
  • For a scripted submit, keeping the document alive and doing the request through the fetch stack, which means you own every state transition the navigation would have handled for you.
  • Managing the resubmission prompt and history entry for POST navigations — behaviour you lose entirely when you intercept and must replace with your own guard.
  • For a large upload, main-thread work is minimal; the time is network and disk. The mistake is blocking the UI on it rather than reporting progress (File Upload APIs: Authorize, Upload Directly, Confirm).

Method is a semantic decision with consequences

The method is not a transport detail. It tells the browser, every intermediary and every crawler what kind of operation this is, and they act on that: caching it, prefetching it, retrying it, storing it in history, or refusing to repeat it without asking.

The usual mistake is choosing POST for everything because it "hides" the parameters. That gives up shareable URLs for search and filter forms and buys nothing in return, since the values remain fully visible to the user in devtools.

  • A state-changing GET will eventually be triggered by a prefetcher, a link checker or an email scanner. This has happened to enough teams that it is a rule, not a caution (GET: The Promise of Safety).
  • A POST that succeeds should redirect, so the resulting history entry is a GET that can be reloaded safely.
  • A GET form is the cheapest way to make search state shareable, and it works before hydration.
GET or POST for this form?

Does submitting this form change server state?

GET

when It only reads: search, filter, sort, pagination. The result should be a URL a user can bookmark and share.

cost Values are in the URL and therefore in history, logs and referrers; the query string has a practical length limit; nothing sensitive can go here (The URL Is Application State).

POST, native navigation

when It changes state and you want the form to work without JavaScript — the progressive-enhancement baseline.

cost A full page load, and a resubmission prompt on reload unless you redirect after success (MPA vs SPA).

POST, intercepted and fetched

when You want an inline result with no navigation, and JavaScript is a hard requirement anyway.

cost You own history, scroll position, error display, duplicate prevention and preserving input on failure.

PUT / PATCH / DELETE via fetch

when The API models the operation that way and the client is scripted regardless.

cost Forms cannot express these methods natively, so the no-JavaScript path is gone unless the server accepts a POST override (PUT vs PATCH).

FormData, enctype and what is on the wire

The gap between "the values are in state" and "the right bytes reached the server" is where two specific bugs live: files that arrive as filenames, and multipart bodies the server cannot parse because the boundary was overwritten.

FormData avoids both, provided you let fetch set the Content-Type itself. The boundary is generated per request and must match the body, which is why a hand-written header breaks it.

Submitting once, with the right encoding
1const form = document.querySelector<HTMLFormElement>('#order')!
2const submitBtn = form.querySelector<HTMLButtonElement>('[type=submit]')!
3let inFlight: Promise<Response> | null = null
4let idempotencyKey = crypto.randomUUID()
5
6form.addEventListener('submit', async (e) => {
7 e.preventDefault() // validation already ran
8 if (inFlight) return // the real guard: one request at a time
9
10 const body = new FormData(form) // files, multi-selects, everything named
11 submitBtn.setAttribute('aria-disabled', 'true') // stays focusable
12 setStatus('Submitting your order…') // announced politely
13
14 try {
15 inFlight = fetch(form.action, {
16 method: 'POST',
17 body, // fetch sets multipart + boundary itself
18 headers: { 'Idempotency-Key': idempotencyKey },
19 })
20 const res = await inFlight
21 if (!res.ok) throw new Error(await res.text())
22
23 idempotencyKey = crypto.randomUUID() // new key only after a real outcome
24 setStatus('Order placed.')
25 document.querySelector<HTMLElement>('#result-heading')?.focus()
26 } catch (err) {
27 setStatus('We could not place your order. Your details have been kept.')
28 } finally {
29 inFlight = null
30 submitBtn.removeAttribute('aria-disabled')
31 }
32})

Three things are load-bearing and easy to drop. No manual Content-Type, or the multipart boundary is lost. The key is regenerated only after a definite outcome, so a retry of an ambiguous timeout reuses it and the server collapses the duplicate. And finally restores the control on the error path, which is where forms most often get stuck.

Making it happen exactly once

Exactly-once is not something a client can provide. The client can make a duplicate unlikely; only the server can make a duplicate harmless. The design therefore has two halves, and shipping only the first is the common mistake.

The timeline below shows the case that a client-side guard cannot cover: the request arrives and commits, the response is lost, and the client — correctly, from its own point of view — retries. Without a key, that is two orders.

A double submit on a slow connection, schematicrelative units — an ordering, not a measurement
Press 1 → submit handler
Request 1 in flight
Press 2 (impatient tap)
Server commits order
Response lost (connection drops)
Client retry with the SAME key K
Server matches K, returns the first result
UI shows success + focus moves
  • Press 1 → submit handlerGuard set, request sent, key K generated.
  • Press 2 (impatient tap)Guarded: inFlight is set, so nothing is sent. The client-side half works here.
  • Server commits orderThe effect has happened. Nothing the client does after this can undo it.
  • Response lost (connection drops)The client cannot distinguish this from a request that never arrived.
  • Client retry with the SAME key KReusing the key is what makes the retry safe.
  • Server matches K, returns the first resultThe server-side half. One order, one confirmation, no duplicate charge (Idempotency Keys: The Mechanism).

The client guard covers presses 1 and 2. Nothing on the client covers the lost response, which is why the key exists and why it must survive the retry.

Duplicate submission: trigger, symptom, cause, response
TriggerSymptomCauseResponse
Double-click on a slow connectionTwo records, one confirmationThe disabled state was applied by a render that had not committed yetGuard on a synchronous in-flight reference in the handler, not on rendered state.
Enter pressed twice in a text fieldTwo submits despite a disabled buttonImplicit submission does not always route through the button's disabled stateGuard in the submit handler, which every activation path goes through.
Request times out, user retriesTwo chargesThe first request committed; only the response was lostReuse the idempotency key on retry and let the server collapse it (Idempotency in Backends).
Two tabs open on the same formConflicting updates, last write winsNo shared client state and no version checkVersion or ETag check server-side; a client guard cannot see the other tab (Optimistic Concurrency: Versions and If-Match).
Retry loop on a 5xxBurst of duplicate writes during an incidentAutomatic retry of a non-idempotent operationOnly retry idempotent requests, and back off with jitter (Retries, and the Duplicate Order).
Success handler after navigation awayConfirmation never shown; user resubmitsThe view unmounted before the response landedOwn the submission above the view, or block navigation while a submit is in flight (Route Loading Boundaries).

How to build it

Most important first.

  • Choose the method by what the submission does, not by convenience. Reads are GET; changes are POST. A form that filters, searches or sorts should produce a URL a user can share.
  • Handle the submit event on the form so Enter, the button and requestSubmit() all take the same path.
  • Use new FormData(form) rather than assembling an object by hand. It picks up every named control including files, multi-selects and the activating submit button, and it stays correct when a field is added.
  • Disable the submit control the moment submission starts and re-enable it in a finally. This is necessary and not sufficient — it does not cover Enter during the disabled window on every platform, nor a duplicate that is already in flight.
  • Make the operation idempotent on the server. Send a client-generated idempotency key with the request so a duplicate is recognised and collapsed rather than executed twice (Idempotency Keys: The Mechanism).
  • Keep the request in a single in-flight reference and return early if one exists, rather than relying only on the disabled attribute.
  • Show progress for anything that can take longer than a moment, and make the terminal states — success and each distinguishable failure — explicit in the UI (Loading, Error, Empty — The States You Did Not Render).
  • Preserve the user's input on failure. Re-rendering an empty form after a failed submit is the most reliable way to lose a customer mid-checkout.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Submission must be reachable by Enter from any text field, not only by activating a specific control. That is implicit submission, and it disappears the moment the button is not a real submit button.
  • A disabled button during submission removes a tab stop and announces nothing about why. Prefer aria-disabled="true" with the click guarded in code, so the control stays focusable and can still explain itself.
  • Announce the transition. "Submitting", then "Order placed" or the specific failure, through a polite live region or by moving focus to a result heading — otherwise a screen reader user gets silence and no evidence anything happened (Live Regions and Announcement).
  • On success without navigation, move focus somewhere meaningful. Leaving focus on a now-disabled button strands keyboard users (Focus Management).
  • Upload progress needs a text equivalent — a progressbar role with a value, or periodic polite updates — not only a moving bar.

What can go wrong

Failure modes
  • Setting Content-Type: multipart/form-data by hand on a FormData fetch. The boundary parameter is missing, so the server cannot parse any part, and the error is a confusing 400 rather than an obvious one.
  • A button without type inside a form defaulting to submit, so a secondary action submits the form (Native Forms First).
  • Disabling the button by re-render after an await. There is a window between the event and the state taking effect, which is exactly the window a double-click lands in.
  • Debouncing the submit handler instead of guarding the request. Debouncing delays; it does not deduplicate an already-sent request.
  • A POST navigation followed by a reload, producing the browser's resubmission prompt. Redirecting after a successful POST is the standard remedy and is easy to forget in an API-first codebase (Status Codes Clients Can Branch On).
  • Cancelling the first request when a second arrives. The first may already have reached the server and committed; cancelling the response does not cancel the effect (Cancelling a Request Nobody Is Waiting For).
  • A retry on timeout with no idempotency key, turning one uncertain outcome into two certain charges (Idempotency in Backends).
  • Optimistically navigating away before the response lands, so a failure has nowhere to be shown (Optimistic UI).
What can arrive out of order
  • Double submit: two requests in flight for one intent. Guard client-side for the common case and collapse server-side with an idempotency key for the rest (Idempotency Keys: The Mechanism).
  • Out-of-order responses: a slow first attempt resolving after a fast retry, so the older result overwrites the newer (Out-of-Order Responses).
  • Timeout ambiguity: the request reached the server, the response did not come back, and the client cannot tell that from a request that never arrived.
  • Navigation during flight: the user leaves before the response, so the success handler runs against an unmounted view or does not run at all.
  • A concurrent edit from another tab or another user submitting between the read and the write, which needs a version check rather than a client-side guard (Optimistic Concurrency: Versions and If-Match).
Security
  • Forms can submit cross-origin. The browser will send the request and the cookies that apply to it; it just will not let script read the response. That asymmetry is the entire basis of CSRF, and SameSite cookie attributes plus a token are the defences (Cross-Site Request Forgery).
  • GET requests get retried, prefetched, logged and cached by intermediaries. Any state-changing GET will eventually be executed by something that is not a user, and the query string ends up in server logs and referrer headers.
  • Never put secrets in a GET form. Query strings are stored in history, in logs and in analytics.
  • Hidden fields are user-editable. The prices, ids and role fields a form submits must all be re-derived or re-authorised server-side (Where Authorization Must Live).
  • File uploads need server-side type, size and content checks; the client's accept attribute is a file-picker filter and nothing more (File Upload Security).
  • The server must be the place duplicates are collapsed. A client-side guard is a UX improvement, not a correctness guarantee, because the request can be replayed outside the browser (Idempotency).
Misreads
  • "POST is more secure than GET." Both are plaintext without TLS and both are fully visible to the user. POST keeps data out of URLs and history; that is a privacy and correctness property, not a security boundary.
  • "Disabling the button prevents double submission." It narrows the window. It does not close it, and it does nothing about a replayed request.
  • "FormData is only for file uploads." It is the entry list for any form, and it is the least error-prone way to read one.
  • "We send JSON, so enctype is irrelevant." It is relevant the moment a file is involved, and FormData is usually simpler than base64 in a JSON body (File Upload APIs: Authorize, Upload Directly, Confirm).
  • "Cancelling the request undoes it." An AbortController stops you from hearing the answer. The server may already have acted.
  • "Retrying a failed submit is safe." Only if the operation is idempotent. Otherwise a retry after a timeout is how one action becomes two.

Measuring it, and what changes in the field

How you would see this
  • The Network panel: one request or two, the method, the Content-Type including the multipart boundary, and the payload view showing the actual parts.
  • Server-side duplicate detection metrics — how often the idempotency key matched an existing request. A non-zero rate is normal and tells you the client guard is not enough (Duplicate Detection).
  • Submission funnel analytics: attempted, succeeded, failed by reason, retried. A high retry rate against a low failure rate usually means the UI is not communicating success (Analytics Events That Answer a Question).
  • Reload after a POST navigation to confirm whether the resubmission prompt appears, which tells you whether a post-redirect-get is in place.
Slow device, slow network, large data, old tab
  • On a slow network, the window between press and response is long enough for a real user to press again. This is not an edge case; it is the normal experience on a congested mobile connection.
  • On a slow device, the gap between the event and a re-render that disables the button widens, so guards based on rendered state fail more often.
  • On a flaky connection, the request may reach the server and the response may not come back. The client cannot distinguish that from a request that never arrived, which is why idempotency belongs on the server (Retries, and the Duplicate Order).
  • On a large upload, the submission is a long-lived operation that needs progress, cancellation and a resume story rather than a spinner (File Upload UX).
  • In an old tab, the form may be posting to an endpoint that has since been deployed away, or with a CSRF token that has expired (Long-Lived Clients and Version Skew).
What this costs
  • Intercepting the submit gives you an inline result and no page reload, at the cost of owning every state transition the navigation handled: history, scroll, resubmission, error display, and preserving input on failure.
  • Idempotency keys require server support and a place to store the key, which is real backend work for a problem the client cannot solve alone.
  • FormData yields strings and files, so typing and coercion move to one boundary. That is a better place for them, but it is not free.
  • A aria-disabled submit that stays focusable needs its click guarded in code, which is slightly more work than the disabled attribute and considerably better behaved.
  • Post-redirect-get costs an extra round trip and is the reason reload does not resubmit. On a slow connection that round trip is visible.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe submit algorithm, entry-list construction, the three enctype values and FormData behave identically across current engines; this is one of the oldest and most stable parts of the platform.
  • BROWSER-SPECIFICThe resubmission prompt after reloading a POST navigation differs in wording and in when it appears — some browsers show a dialog, others silently re-issue on a back-forward navigation — so post-redirect-get is the portable answer rather than relying on any one browser's prompt.
  • NETWORK-SPECIFICDouble submits and timeout ambiguity are rare on a fast, stable connection and routine on a congested mobile one, so the importance of server-side idempotency scales directly with how bad your users' networks are.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Architectureidempotency
Domains that do not exist yet
  • Distributed Systems — "the request arrived but the response did not" is the classic two-generals shape, and it is why exactly-once delivery is a property of the receiver rather than of the sender.