When an async validator’s request times out or returns 429 Too Many Requests, most implementations do one of two wrong things: they report the value as invalid (“This username is taken”) because the check did not return “available”, or they leave the field spinning in “Checking…” forever and block the submit button with it.
Neither outcome is about the user’s input. A failed check means “we do not know”, and the form should say so and move on. This page, within asynchronous validation strategies, adds explicit timeouts, interprets 429 and Retry-After, retries politely, and gives the field a third state — unknown — that is visible, accessible and non-blocking.
Context and prerequisites
The outcomes an async validator must distinguish:
- Valid — the server answered and the value is acceptable.
- Invalid — the server answered and the value is not acceptable (“taken”, “not a real postcode”).
- Unknown — no answer: network error, timeout, 429, 5xx. The value may be fine.
- Superseded — the user changed the value; this result no longer matters (handled by cancellation).
fetch has no timeout by default; a hung connection can wait minutes. AbortSignal.timeout(ms) creates a signal that aborts after ms, and AbortSignal.any([a, b]) combines it with the per-keystroke cancellation signal, so either cause stops the request — and they can be told apart by the abort reason’s name (TimeoutError versus AbortError).
The core pattern: a resilient check with an explicit unknown state
export type CheckResult =
| { state: "valid" }
| { state: "invalid"; message: string }
| { state: "unknown"; message: string; retryAfterMs?: number };
const TIMEOUT_MS = 5000;
export async function checkUsername(value: string, cancel: AbortSignal): Promise<CheckResult> {
// Either the user's next keystroke (cancel) or the timeout stops the request.
const signal = AbortSignal.any([cancel, AbortSignal.timeout(TIMEOUT_MS)]);
let res: Response;
try {
res = await fetch(`/api/usernames/${encodeURIComponent(value)}`, { signal });
} catch (err) {
if (cancel.aborted) throw err; // superseded: caller ignores it
const timedOut = (err as DOMException)?.name === "TimeoutError";
return { state: "unknown", message: timedOut
? "We couldn't check this username right now. You can still continue."
: "You seem to be offline. We'll check this username when you submit." };
}
if (res.status === 429) {
return { state: "unknown", message: "We couldn't check this username right now. You can still continue.",
retryAfterMs: parseRetryAfter(res.headers.get("Retry-After")) };
}
if (res.status >= 500) {
return { state: "unknown", message: "We couldn't check this username right now. You can still continue." };
}
if (res.status === 404) return { state: "valid" }; // no such user: available
if (res.ok) return { state: "invalid", message: "That username is taken. Try another." };
return { state: "unknown", message: "We couldn't check this username right now." };
}
// Retry-After is either delay-seconds or an HTTP date.
export function parseRetryAfter(h: string | null): number | undefined {
if (!h) return undefined;
const secs = Number(h);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
const at = Date.parse(h);
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
}
// Field-level policy: one automatic retry for unknowns, honouring Retry-After,
// and never more often than the server allows.
export function scheduleRetry(result: CheckResult, retry: () => void, alreadyRetried: boolean) {
if (result.state !== "unknown" || alreadyRetried) return undefined;
const base = result.retryAfterMs ?? 2000;
const delay = base + Math.random() * 500; // jitter: avoid synchronised retries
return setTimeout(retry, delay);
}
The field renders the three states differently: valid shows a quiet confirmation, invalid shows an error and sets aria-invalid, and unknown shows a neutral note without aria-invalid and without blocking submit.
Step-by-step walkthrough
- Give every check a timeout.
AbortSignal.timeout(5000)bounds the wait; combine it with the cancellation signal usingAbortSignal.any. - Tell the abort causes apart. A
TimeoutErrormeans “unknown”; anAbortErrorfrom the user’s own signal means “superseded” — do nothing. - Map status codes to verdicts, not to errors. Only a successful response with a clear answer can make the field invalid. 429 and 5xx are unknown.
- Honour
Retry-After. Retrying before the server allows prolongs the rate limit and is impolite. If no header is present, use a modest delay with jitter. - Retry once, then stop. An unknown state is acceptable; hammering the endpoint is not. Retrying at submit time is the natural second chance.
- Let unknown through to submit. The server performs the same check at submit and is authoritative; route its answer back with the field mapping in mapping 422 responses to field errors.
Why “unknown” must not block
Blocking submit whenever an availability check fails turns an outage in a non-essential service into an outage of your whole sign-up flow. The availability check exists to give users early feedback, not to protect data — the server enforces uniqueness at submit regardless. So an unknown result costs almost nothing if it lets the user continue: at worst, they learn at submit that the username is taken and pick another. Blocking, by contrast, costs every user who arrives during a rate-limit window or a slow deploy. Design the unknown state as a normal, calm part of the UI.
Failure modes and edge cases
1. AbortSignal.any support
AbortSignal.any and AbortSignal.timeout are available in current browsers. For older targets, create a controller, setTimeout its abort with a TimeoutError reason, and forward the cancellation signal’s abort to it manually.
2. Caching unknowns
Never store an unknown result in a validation cache — the next check should try again. The caching rules are in caching async validation results.
3. Retries that outlive the field
A scheduled retry must be cleared when the value changes or the form unmounts; otherwise it fires for a value the user abandoned. Keep the timer id with the field’s check state and clear it in the same place you abort the request.
4. Retry storms after an outage
When a service recovers, every open form retrying at the same moment can knock it over again. Jitter and a single retry per field keep the load spread; honouring Retry-After lets the server shape it.
5. Wording that blames the user
“Invalid username” on a timeout tells the user their input is wrong. Say what happened and what they can do: “We couldn’t check this username right now. You can still continue.”
Verification checklist
Frequently Asked Questions
What timeout should an availability check use?
Long enough for a slow mobile connection and a busy server, short enough that the user is not left wondering — 3 to 8 seconds is typical. Measure your endpoint’s p99 and set the timeout comfortably above it.
Should I show the unknown state at all, or just hide the indicator?
Show it briefly and neutrally. Hiding it leaves users unsure whether the check happened, and a silent success indicator that never appears looks like a bug. A short note plus letting them continue is honest and low-friction.
How do I avoid 429s in the first place?
Debounce checks, skip them until synchronous validation passes, cache answers, and deduplicate in-flight requests. Together these usually reduce requests by an order of magnitude compared with checking every keystroke.