The typical async-check UI is a spinner icon inside the input: sighted users see it flicker on every pause in typing, screen-reader users hear nothing at all, and nobody can tell whether the check finished or silently failed.
A good pending state answers three questions for everyone: is something being checked, did it finish, and what was the answer — without interrupting typing or flooding assistive technology with announcements. This page builds that presentation layer for the validators in asynchronous validation strategies, using a delayed indicator, a single polite status message per outcome, and aria-busy where it helps.
Context and prerequisites
The pieces available:
- A visual indicator — text (“Checking…”) or an icon with text, next to the field. Icons alone are not enough for users who do not recognise them.
aria-describedby— links the field to its status text, so the current status is read when the field is focused.- A live region (
role="status", which is polite) — announces changes without moving focus. Announcements should be rare: one when a result arrives, not one when checking starts. aria-busy="true"— tells assistive technology a region is being updated and to wait before reading changes. Useful on the status element while it is about to change; support varies, so do not rely on it alone.
Timing matters as much as markup. Most checks return in under half a second; showing a spinner immediately produces a flicker on every check. Delay the visual indicator (around 300–400 ms), and never announce “checking” at all — announce only results.
The core pattern: a status controller with delayed display
type Result = { kind: "valid"; text: string } | { kind: "invalid"; text: string } | { kind: "unknown"; text: string };
export class AsyncFieldStatus {
private showTimer: ReturnType<typeof setTimeout> | undefined;
private lastAnnounced = "";
constructor(
private input: HTMLInputElement,
private status: HTMLElement, // visible text, linked via aria-describedby
private live: HTMLElement, // role="status", visually hidden or shared page-level
private delayMs = 400,
) {
const ids = new Set((input.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean));
ids.add(status.id);
input.setAttribute("aria-describedby", [...ids].join(" "));
}
pending() {
clearTimeout(this.showTimer);
this.status.setAttribute("aria-busy", "true");
// Delay the VISUAL indicator so fast answers do not flicker.
this.showTimer = setTimeout(() => {
this.status.textContent = "Checking…";
this.status.dataset.state = "pending";
}, this.delayMs);
}
settle(r: Result) {
clearTimeout(this.showTimer);
this.status.removeAttribute("aria-busy");
this.status.textContent = r.text;
this.status.dataset.state = r.kind;
// Only a real error marks the field invalid; "unknown" does not.
if (r.kind === "invalid") this.input.setAttribute("aria-invalid", "true");
else this.input.removeAttribute("aria-invalid");
// Announce each distinct result once. Identical repeats (e.g. from a cache
// hit after editing back) are not re-announced.
if (r.text !== this.lastAnnounced) {
this.live.textContent = "";
requestAnimationFrame(() => { this.live.textContent = r.text; });
this.lastAnnounced = r.text;
}
}
cancel() { // value changed before the result arrived
clearTimeout(this.showTimer);
this.status.removeAttribute("aria-busy");
this.status.textContent = "";
this.status.dataset.state = "";
}
}
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username">
<p id="username-status" class="field-status"></p>
<div id="form-live" role="status" class="visually-hidden"></div>
Step-by-step walkthrough
- Give the field a status element linked by
aria-describedby. Whatever the status says is read when the user focuses the field, so the latest result is always discoverable. - Delay the visible “Checking…” indicator. 300–400 ms after the check starts. Most checks finish sooner, so most users never see a flicker; slow checks still show progress.
- Do not announce “checking”. An announcement on every pause interrupts screen-reader users mid-thought. Announce only results.
- Announce each result once, politely. Clear and re-set the live region’s text so the change is detected; skip repeats so a cache hit does not re-announce the same message — see throttling live region announcements.
- Set
aria-invalidonly for real errors. “Couldn’t check right now” is not an error; the unknown state from handling timeouts and 429s in async validators leaves the field valid. - Decide what submit does while a check is pending. Either wait for the check (showing “Checking your username…” on the button) or submit and let the server decide. Never silently block.
Why the result, not the process, is what to announce
Announcing process (“Checking username… Username available”) doubles the speech for every check and makes typing in a field with async validation noticeably slower for screen-reader users, who hear an interruption at every pause. Sighted users get the process from a glance at a small indicator they can ignore; the audio equivalent cannot be ignored. The result is the information that matters, and the status text linked with aria-describedby lets anyone who wants the current state ask for it by moving focus back to the field. This is the same principle as announcing errors on blur rather than on every keystroke.
Failure modes and edge cases
1. Spinners inside the input
An icon absolutely positioned inside the input overlaps typed text on narrow fields and has no text alternative. Put status text next to or below the field instead.
2. Live regions added dynamically
A live region inserted into the DOM at the same moment as its text is often not announced, because assistive technology has not registered it yet. Render the region empty on page load and change its text later.
3. Every field with its own live region
Several simultaneous live regions compete, and some screen readers drop overlapping announcements. One shared page-level role="status" region, fed by every field’s status controller, is more predictable.
4. Colour-only status
A green or red border to show the result fails for colour-blind users and in high-contrast modes. Use text, and an icon with text if you like; the rules are in error styling that does not rely on colour.
5. Reduced motion
A spinning icon should respect prefers-reduced-motion: replace rotation with a static icon or a subtle opacity change. Text-only “Checking…” needs no adjustment.
Verification checklist
Frequently Asked Questions
Is aria-busy enough on its own?
No. Support and behaviour vary across screen readers, and it communicates nothing to sighted users. Treat it as a hint that helps some assistive technology avoid reading half-updated content, alongside visible text and a single announcement.
Should success be announced?
For availability checks, yes — “ada_l is available” is useful feedback that the user is waiting for. For checks the user did not ask about (a silent address normalisation), announce only problems.
What about role="alert" for invalid results?
Reserve alert (assertive) for errors that need immediate attention, such as a failed submit. An availability result arrives while the user is still working in the field; polite is right, as discussed in choosing between alert and status regions.