Skip to content
System nominalUTC --:--:--Articles 041

Forms That Survive a Slow Network and an Impatient Tap

A form loses work in ordinary ways: the double submit, the response that never arrives, the validation error that clears the fields, the back button.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Topic
FE
Section
Frontend & Accessibility
Published
13 MAY 2026
Reading time
8 MIN
Words
1976W
Technical cover graphic for Forms That Survive a Slow Network and an Impatient Tap
Generated schematic — FE series, REV 2.6.

The form works on the developer's connection. Type, click, wait under a second, confirmation page. On a phone on a train it fails in ways that never show up locally: the button gets tapped twice, or the response arrives after the user has given up and navigated away, or a validation error wipes twenty filled fields.

None of that is exotic. It is ordinary network behaviour and ordinary human impatience, and it has ordinary fixes. The order matters, because the cheap fixes remove most of the damage and the expensive ones cover the remainder.

Everything below assumes a form that writes something: an order, a booking, a support request, a delivery instruction. If the form only searches, most of this is unnecessary.

A double submit is the first failure, not the last

Ordered by how often it happens, the double submit wins by a wide margin. A user taps, sees no change for two seconds, taps again. Two requests leave the browser, both in flight, which is why rate limiting by IP address does not help: the two requests are indistinguishable from one user doing something twice on purpose.

The second most common failure is the response that never arrives. The write committed on the server. The connection dropped before the confirmation reached the browser. The user sees a spinner stop and assumes nothing happened, then submits again.

Third is the validation error that clears the form. The server rejects one field, renders the page fresh, and every other value is gone. Fourth, and closely related, is the back button. The user submits, the confirmation page loads, they press back to check what they typed, and they get a form reset to its initial state with no indication of what was already accepted.

Failure modeSymptomFix
Double submitTwo records, one intentGuard the control on submit, and make the write idempotent
Lost responseWrite succeeded, user believes it failedRequest token so the retry returns the first result
Validation errorForm comes back empty after a rejectionRe-render with the submitted values and name the failing field
Back buttonConfirmed work looks unsent, or the form is blankDraft in sessionStorage, cleared after success
Autofill mismatchBrowser fills the wrong field, or refuses to fillCorrect autocomplete tokens, one per input

Disable the control and say what it is doing

The cheapest defence is the one users can see. On submit, mark the control busy and change its label. What you should not do is ignore the second click silently, because to the user that reads as a frozen page, and the next thing they do is reload.

Disabling a button outright carries an accessibility cost that is easy to miss: a control with the disabled attribute leaves the tab order, so a keyboard user loses their place at exactly the moment they need feedback. The pattern that avoids this keeps the control focusable and reports the state in a separate live region.

<!-- Inside the form element, next to the fields. -->
<p class="wpsifero-actions">
  <button type="submit"
          id="wpsifero-submit"
          class="button"
          aria-describedby="wpsifero-form-status">Send request</button>
  <span id="wpsifero-form-status"
        role="status"
        class="wpsifero-status"></span>
</p>

The button has to change its label, the live region has to speak, and a second submission has to be refused before it leaves the browser:

const form   = document.querySelector( '.wpsifero-form' );
const button = form.querySelector( '#wpsifero-submit' );
const status = form.querySelector( '#wpsifero-form-status' );
let busy = false;

form.addEventListener( 'submit', ( event ) => {
  if ( busy ) {
    event.preventDefault();
    return;
  }

  busy = true;
  button.setAttribute( 'aria-disabled', 'true' );
  button.textContent = 'Sending…';
  status.textContent = 'Sending your request.';
} );

If JavaScript never arrives, none of this runs and the form still submits once, because that is what a submit button does natively. The guard is an improvement on a working baseline rather than a replacement for one.

It also fails in one important case. A repeat request does not have to come from a click. A proxy can retry, a flaky mobile connection can duplicate the request, and a user who reloads can accept the browser's resubmit prompt. The client guard is a courtesy. The server-side idempotency below is the actual protection.

Server-side idempotency makes the second attempt harmless

Two requests carrying identical content should produce one record. The reliable way to get there is a token generated when the form is rendered, stored with the submission, and checked on every attempt. That token is not a nonce and does not replace CSRF protection. It identifies one intent to submit, which is a different job from proving where the request came from.

Generate it at render time, put it in a hidden field, and store it in a column with a unique constraint, so the database enforces the rule rather than your PHP code noticing quickly enough:

ALTER TABLE wp_wpsifero_requests
  ADD COLUMN request_token CHAR(36) NOT NULL,
  ADD UNIQUE KEY request_token ( request_token );

On the second attempt the insert fails on the duplicate key. Catch that specific error, read the row that already exists by token, and return the same response the first attempt produced. The user gets the confirmation they were owed and the table stays at one record. This is also the fix for the lost-response case, because the retry after a dropped connection takes exactly that path.

None of it holds if the response is generated fresh each time. If the second attempt emails a receipt or fires a webhook, the row count is right and the customer has two receipts. Keep the side effects keyed to the same token as the row, so the downstream work happens once as well. The rule I would write on the ticket: the token owns the whole operation, not just the row it protects.

Validation errors must come back carrying the submitted values

Server-side validation is the only validation that counts, because a check in the browser is a convenience for the user and nothing more. The fix for the clearing problem is not clever. Render the form again with the submitted values in place, put the message next to the field that failed, and add one summary at the top that lists the failures in plain language.

Never echo a value into markup without escaping it for the context it lands in. A value that goes into an attribute, a text area body or a script block needs different treatment in each case. And validate before you write anything, so a rejected submission leaves no partial record behind for support to find later.

Where the browser can help is refilling, and that depends on autocomplete tokens being correct. The tokens come from the HTML specification and they are narrow: name for a full name, given-name, family-name, email, tel, street-address, postal-code, one-time-code, cc-number. A field labelled "Work email" with autocomplete="email" is correct. The same token on a form that asks for a colleague's address is not, and the browser will confidently fill your own address into it.

<label for="wpsifero-email">Email for the receipt</label>
<input id="wpsifero-email" name="email" type="email"
       autocomplete="email" value="ana@example.test">

<label for="wpsifero-postcode">Delivery postcode</label>
<input id="wpsifero-postcode" name="postcode" type="text"
       inputmode="numeric" autocomplete="postal-code" value="28013">

Both fields arrive pre-filled because the server re-rendered them with what it received, and both carry an autocomplete token the browser understands.

autocomplete="off" is a request rather than a guarantee: browsers ignore it on fields they recognise, and password managers treat it as a suggestion. And a value you print into a field is a value the browser will not overwrite, so a stale saved draft can shadow autofill if you restore one without thinking about the order.

sessionStorage covers the accidental navigation

Draft persistence handles the failure the server cannot see: a user who navigates away, presses back, or closes a tab before submitting. Storing the field values in sessionStorage lets that tab restore them without a round trip, and the store is scoped to the tab and to the origin, so it does not follow the user into the next one.

const KEY  = 'wpsifero-request-draft';
const form = document.querySelector( '.wpsifero-form' );

function saveDraft() {
  const data = {};
  for ( const field of form.elements ) {
    if ( field.name && 'file' !== field.type && 'password' !== field.type ) {
      data[ field.name ] = field.value;
    }
  }
  sessionStorage.setItem( KEY, JSON.stringify( data ) );
}

const stored = sessionStorage.getItem( KEY );
if ( stored ) {
  const data = JSON.parse( stored );
  for ( const field of form.elements ) {
    if ( field.name && field.name in data ) {
      field.value = data[ field.name ];
    }
  }
}

form.addEventListener( 'input', saveDraft );
form.addEventListener( 'submit', () => sessionStorage.removeItem( KEY ) );

The snippet skips file and password inputs, because neither can be restored meaningfully and one of them should never be written to storage at all. The clear runs on submit rather than on the confirmation page, so the draft does not outlive the request when the response goes missing.

Writing on every input event is also more traffic than it looks. sessionStorage is synchronous, so on a cheap Android device with a thirty-field form I have seen the write show up as tens of milliseconds of input lag. Saving on a short timer, or on change and pagehide, costs a few lost keystrokes and gives the typing response back.

The storage itself is the limit, and it is a hard one. sessionStorage holds plain text in the browser. Anyone who uses that profile or that device afterwards can read it, and it is not protected by anything your server controls. Card numbers, national identity numbers, health details, one-time codes and anything else that would be a problem on a shared laptop have no business in a draft. Store only the fields that are expensive to retype and cheap to expose, and keep the payment fields out by name.

If you need the draft on a form that is genuinely sensitive, the storage has to move to the server, keyed to a session or a soft token, with a retention period you can state in the privacy policy. That is a real project rather than a snippet, and for most forms it is not worth the work.

Optimistic UI is a bad trade when the record is money-adjacent

The pattern that shows an order as confirmed before the server has agreed saves latency on a fast connection and creates work on a slow one. Its whole premise is displaying a state that has not happened yet. For a search box, a like button or a draft comment, being wrong costs a refresh. For a submission that creates an order, a booking or a payment, being wrong means a customer who believes they have bought something.

You end up building the reconciliation path anyway: a banner that admits the order did not go through, an apologetic email, a support queue, a refund. Optimism has not removed the work. It moved it later, into a shift where nobody who wrote the code is on duty.

Where it does pay is a read-mostly or trivially reversible action. Marking a notification read, toggling a wishlist item, reordering rows in a list. There the retry is cheap and the wrong state survives for about a second.

What to do on Monday

  1. Open the form on a throttled connection and press submit four times. Count the records.
  2. Add the token, the hidden field and the unique constraint, then repeat the first step.
  3. Mark the control busy on submit, change the label, and announce the state in a live region. Check the whole sequence with the keyboard only.
  4. Break one validation rule on purpose and confirm every other field comes back filled.
  5. Audit the autocomplete token on every field, and remove any that describes a person other than the one filling the form.
  6. Add the draft for the fields worth saving, and make sure it clears on submit instead of on the page that happens to follow it.

Priya Raghunathan · @praghunathan

Frontend and accessibility

Reviews interfaces for what happens when JavaScript fails, the keyboard is the only input, and the connection is slow. Focused on Core Web Vitals, semantic markup and progressive enhancement.

Continue

Related in Frontend & Accessibility