Building · 2026-08-12 · 9 min read · By Arden Talbot, founder of SheetLink
The error states nobody designs
Network failures, rate limits, the disabled-button trap. The unglamorous half of forms is what a visitor sees when things go wrong - and most teams have never once looked at it.
The happy path bias
Every form gets designed once, on a fast connection, with valid input, against a healthy server. That is the version in the mockup, the version in the demo, the version the team signs off on. The other versions - the submit that times out, the rate limit that triggers, the validation that rejects - are usually rendered by whatever the browser or the framework does by default, which is to say they are not designed at all.
This matters because failure is precisely when the stakes are highest. A visitor who hits an error is a visitor who wanted to reach you and could not; what happens in the next three seconds decides whether they try again or leave. This essay is a tour of the failure states a form actually has, and what each one should look like to the human on the other side.
A taxonomy of failure
Form failures come in four families, and they deserve different treatment because the user can do different things about each. Validation failures: the input is wrong and the user can fix it. Network failures: the request never completed and nobody knows what happened. Server rejections: the request arrived and was refused - a rate limit, an origin check, an oversized payload. And delivery failures: the submission was accepted but the destination hiccuped afterward - the family users never see, if the backend is built properly.
Most forms collapse all four into one generic "something went wrong," which is precisely wrong: it tells the fixable case nothing useful and the unfixable case to keep trying.
Validation: the solved problem we keep unsolving
Validation errors are the only failures with decades of settled guidance, covered in the research we walked through in our form UX essay: inline, adjacent to the field, specific, in plain language. NN/g's error reporting guidelines and the GOV.UK Design System's error message standard agree on the essentials, down to the wording.
The one addition worth making here: validate on the server even if you validate in the browser, and return errors the client can attach to fields. Client-side validation is a courtesy; server-side validation is the contract. Any endpoint reachable by curl will eventually receive input no browser produced.
Network failure: the request that never lands
A fetch that fails leaves the client in genuine uncertainty - the request may have died on the way out or the response on the way back. The one unforgivable response to that uncertainty is silence. A spinner that spins forever, a button that swallowed the click: the visitor is left interrogating a static page.
Design the state explicitly. Say what is known: "We could not reach the server. Your message is still here - check your connection and try again." That second sentence is load-bearing, and it points at the cardinal rule of network failure: never discard the user's input. The typed message must survive the failed attempt untouched. And because the client cannot know whether the first request arrived, the endpoint should tolerate a duplicate - a resubmission costing a duplicate row beats a lost lead every time. A duplicate is one click to delete in a sheet; a vanished message is a relationship that ended silently.
What a 429 should look like to a human
Rate limits exist to keep endpoints alive under abuse, and forms sit behind them - per form and per IP, in our case. Occasionally a human trips one: a shared office IP, an over-eager double click, a burst of legitimate enthusiasm.
The protocol has a vocabulary for this. A 429 response can carry a Retry-After header naming the seconds to wait - which means the client can translate a protocol event into a human sentence: "Too many submissions right now - try again in a minute. Your message is saved below." What a 429 must never become is a blame message. The visitor did nothing wrong; the system is pacing itself. Copy that scolds ("You have submitted too many times") converts a temporary condition into a permanent impression.
The disabled-button trap
Here is the most common self-inflicted error state on the web. To prevent double submission, the click handler disables the submit button. The request fails - network blip, 429, anything. The error path updates a message somewhere, but nobody wrote the line that re-enables the button. The visitor now faces a form full of their own words and a control that is permanently dead. They will refresh, and lose everything, and not come back.
The trap is structural: disabling is done optimistically at the start of the happy path, re-enabling is a chore owed by every failure path, and failure paths are exactly the code nobody exercises. The fix is to make re-enablement unconditional - the JavaScript idiom is a finally block - so no failure, present or future, can strand the button. Better still, pair a live button with idempotent handling on the server, so the double click the disabling was meant to prevent is harmless anyway. Our AJAX submission tutorial builds the pattern out in full.
What server-side retries change
The fourth failure family - the destination stumbling after acceptance - is where architecture quietly redraws the client's job. If the endpoint delivers synchronously, every downstream wobble becomes the visitor's problem: Google has a slow moment, the visitor watches a spinner. If the endpoint accepts, persists, and delivers asynchronously - as described on how it works - then acceptance and delivery are separated, and each side owes less.
SheetLink Forms retries failed deliveries at 5 minutes, 30 minutes, and 2 hours, with every attempt recorded in the delivery log. The consequence for the client is worth stating precisely: once the endpoint says {"ok":true}, the client's job is finished. It owes the user an honest acknowledgment of receipt - not a guarantee that a row exists in a spreadsheet at this instant. The client never needs its own retry loop for delivery, only for submission itself. That division of labor is the difference between a form that must model the whole pipeline's health and one that must model a single request.
Quarantine: the failure state that is not one
One more state hides between success and failure. Spam heuristics are probabilistic, and a borderline submission - a link-heavy message, a disposable email domain - has to go somewhere. The silent-drop design sends it nowhere and tells no one, which converts a heuristic's bad day into a lost lead that no error state could have surfaced, because no error occurred.
The quarantine model resolves the ambiguity differently: the visitor sees normal success, the submission waits in a review queue, and the form owner approves it with one click. The visitor-facing error state for suspicion is deliberately nothing - because the visitor might be legitimate, and the cost of being wrong lands on the owner's review queue for seconds instead of on the relationship forever.
The no-JavaScript error path
Forms that submit the baseline way - a plain POST, a 303 redirect on success, per the redirect guide - have their own failure story, and it is worth respecting rather than dismissing. The browser handles network failure with its own error page, ugly but honest, and the back button restores the form. What the endpoint owes this path is a human-readable error page for rejections - not a bare JSON blob or a blank 500 - and the same input-preserving courtesy where possible.
The baseline path is also the reason to keep error handling on the server honest rather than leaning entirely on client-side polish: some fraction of your submissions will always arrive without your JavaScript present, as our progressive enhancement essay argues at length.
A checklist for the unglamorous half
The audit takes an afternoon. Kill your connection and submit: is there a designed message, and did your text survive? Trip the rate limit: does the copy give a timeframe without blame? Fail a request mid-flight: is the submit button alive afterward? Submit invalid data with curl: does the server return field-level errors? Ask where a suspicious-but-real submission goes: can a human recover it? Ask what happens when the spreadsheet API is down: does the visitor wait, or does a retry schedule own the problem?
Half of those questions are client work. The other half is what you are buying when you choose an endpoint - the receipts are in the docs, and the whole loop is visible in the live demo. SheetLink Forms is free to start, no invite required - start free.
FAQ
What should a form show when a fetch fails?
A designed message that admits uncertainty, preserves the typed input visibly, and offers retry: "We could not reach the server - your message is still here, try again." Never an infinite spinner, and never a cleared form. The user's words are the one thing a network error must not cost.
What is the right way to handle a 429 response in form UI?
Read the Retry-After header if present, translate it into a plain sentence with a timeframe, keep the input intact, and avoid blame language. A rate limit is the system pacing itself, not the user misbehaving.
What is the disabled-button trap exactly?
Disabling submit on click to prevent duplicates, then failing to re-enable it when the request errors - stranding the user with a dead button and a full form. The cure is unconditional re-enablement on completion (success or failure) and server-side tolerance of duplicates so the disabling matters less.
Should my form retry failed submissions automatically?
Retry submission only with the user's participation - a visible try-again affordance - because automatic client retries can duplicate without consent or context. Delivery retries are different: they belong on the server, where SheetLink Forms runs them at 5 minutes, 30 minutes, and 2 hours against the destination API.
If the endpoint returns ok, is the row already in my sheet?
Not necessarily at that instant - acceptance and delivery are deliberately separated. The submission is persisted immediately and a worker writes the row, retrying on a backoff schedule if the destination stumbles. The delivery log shows every attempt and outcome.
How do I test error states without breaking production?
Simulate at the client: browser devtools can force offline mode and block specific requests, which exercises the network-failure and stranded-button paths against any endpoint. For rejection paths, submit deliberately oversized or malformed payloads - the 256KB cap and validation rules respond predictably.
What happens to a submission flagged as suspicious - does the visitor see an error?
No. Suspicious submissions are quarantined for one-click review, and the visitor sees normal success - deliberately, because they may be legitimate. Only the honeypot marks spam outright. The reasoning is laid out in quarantine vs CAPTCHA.
Do error states matter for low-traffic forms?
Proportionally more. On a form receiving five submissions a week, each one is a meaningful fraction of everything the form exists to do - and failure states are exactly where low-traffic forms lose leads invisibly, because there is no volume to make a pattern obvious in analytics.
Let the server own the ugly half
Accepted fast, delivered with retries, logged where you can see it - free to start.
Start freeSee the live demoThe form UX details that raise conversionPost/Redirect/Get: a biography of the web's quietest pattern
