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).

Response classes and the validator's verdict A 2xx answer with an acceptable value makes the field valid. A 2xx answer with an unacceptable value makes it invalid and blocks submit. A timeout or network error makes it unknown, retries once, and does not block submit. A 429 makes it unknown, waits for the Retry-After time before any retry, and does not block submit. A 5xx makes it unknown, retries with backoff, and does not block submit. In every unknown case the server re-checks at submit. Outcome Field state Retry Blocks submit 2xx: acceptable valid — no 2xx: not acceptable invalid — yes timeout / network unknown once, soon no 429 unknown after Retry-After no 5xx unknown backoff no

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

  1. Give every check a timeout. AbortSignal.timeout(5000) bounds the wait; combine it with the cancellation signal using AbortSignal.any.
  2. Tell the abort causes apart. A TimeoutError means “unknown”; an AbortError from the user’s own signal means “superseded” — do nothing.
  3. 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.
  4. 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.
  5. Retry once, then stop. An unknown state is acceptable; hammering the endpoint is not. Retrying at submit time is the natural second chance.
  6. 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.

A 429 during a username check The validator requests the username check. The server responds 429 with Retry-After of 3 seconds. The field shows a neutral message saying the username could not be checked right now, without marking it invalid, and submit remains available. After three seconds plus jitter, one retry is sent. The server responds that the username is available, and the field shows it as valid. Field Validator Server GET /usernames/ada_l 429, Retry-After: 3 unknown: "couldn't check right now" retry after ~3 s 404: available valid

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.”

The three visible states of an async field In the valid state the field shows a quiet confirmation, has no aria-invalid and allows submit. In the invalid state it shows an error message linked with aria-describedby, sets aria-invalid true and blocks submit. In the unknown state it shows a neutral note linked with aria-describedby, does not set aria-invalid and allows submit, because the server will check again. Valid "ada_l is available" No aria-invalid. Submit allowed. Invalid "That username is taken." aria-invalid="true". Submit blocked. Unknown "We couldn't check right now." No aria-invalid. Submit allowed; server re-checks.

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.


Related

← Asynchronous Validation Strategies