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
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 mode | Symptom | Fix |
|---|---|---|
| Double submit | Two records, one intent | Guard the control on submit, and make the write idempotent |
| Lost response | Write succeeded, user believes it failed | Request token so the retry returns the first result |
| Validation error | Form comes back empty after a rejection | Re-render with the submitted values and name the failing field |
| Back button | Confirmed work looks unsent, or the form is blank | Draft in sessionStorage, cleared after success |
| Autofill mismatch | Browser fills the wrong field, or refuses to fill | Correct 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
- Open the form on a throttled connection and press submit four times. Count the records.
- Add the token, the hidden field and the unique constraint, then repeat the first step.
- 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.
- Break one validation rule on purpose and confirm every other field comes back filled.
- Audit the
autocompletetoken on every field, and remove any that describes a person other than the one filling the form. - 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