Async validation and live errors
Live validation is helpful — until it screams every keystroke. Politeness, debouncing, and knowing when to stay quiet.
Live validation is a generous feature — it tells the user the email is taken before they hit submit. Done badly, though, it's a tiny noisy gremlin: a screen reader user types "j", and it shouts "Invalid email." Types "ja", "Invalid email." Types "jav", "Invalid email." They quit. So the question isn't should you validate live — it's when, how loudly, and after how long?
This lesson is mostly about restraint. The ARIA part is small; the timing decisions are everything.
Live regions: polite vs assertive
A live region is an element that, when its content changes, gets announced by screen readers without the user navigating to it. Two settings matter:
aria-live="polite"— wait until the screen reader finishes whatever it's saying, then announce. Safe for almost everything.aria-live="assertive"— interrupt immediately. Use for emergencies. "Your session is about to expire," not "Email taken."
role="status" is shorthand for polite; role="alert" is shorthand for assertive. Same effect, less typing.
<p id="email-status" role="status" aria-live="polite"></p>
When your JS sets the text content, the screen reader reads it. Set it to empty to "clear" the announcement (no announcement happens for empty content).
Don't use aria-live="assertive" for routine validation. Interrupting the screen reader is rude and trains users to ignore your messages.
Don't validate on every keystroke
The single biggest mistake in live validation: firing on every input event. The user types "n", you check, fail. They type "na", you check, fail. By the third keystroke they're listening to a metronome of failure.
Two fixes work, often together:
1. Debounce. Wait until the user has been quiet for some short interval (300–600ms) before validating.
let timer;
emailInput.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => validate(emailInput.value), 500);
});2. Only validate after the user is "done" — on blur. When they tab to the next field, run the check.
emailInput.addEventListener("blur", () => validate(emailInput.value));Blur-based is calmer; debounce is more responsive. For most forms, blur is the right default and debounce is for fields where instant feedback is genuinely useful — search-as-you-type, password strength meters.
Async checks (server side)
If the validity question requires the server — "is this username taken?" — you've got an additional gotcha: the answer arrives later than the keystroke that triggered it. Two things to handle:
Race conditions. Cancel pending requests when a new one fires. The latest answer wins.
Loading state. Tell the user something is happening. "Checking…" in the live region, or a spinner with aria-busy="true" on the input.
let controller;
async function checkUsername(value) {
controller?.abort();
controller = new AbortController();
status.textContent = "Checking…";
try {
const res = await fetch(`/api/check?u=${value}`, { signal: controller.signal });
const { available } = await res.json();
status.textContent = available
? "Username available"
: "That username is taken";
} catch (e) {
if (e.name !== "AbortError") status.textContent = "Couldn't check — try again";
}
}AbortController lets you cancel an in-flight fetch. Without it, a slow response from a stale keystroke can overwrite the correct one from the latest keystroke. Subtle and incredibly annoying.
Announcing success too
When the field becomes valid, say so — once. "Username available." Not "Valid!" every keystroke as the user keeps typing.
The pattern: announce on the transition (invalid → valid), not on every check that comes back valid. Keep a flag for the last announced state and skip if unchanged.
A green check icon next to the field needs a sibling text or aria-label. An icon alone isn't announced.
The blur-then-validate pattern
For most fields most of the time, this is the well-mannered default:
- User types — no announcements.
- User leaves the field (
blur) — validate, announce result politely. - User comes back to fix it — clear the announcement, no re-announce on focus.
- They fix it and blur — announce success once.
This pattern doesn't fire while the user is mid-thought. It fires once they've signaled "I'm done with this field for now." The cognitive load matches the user's intent.
let lastState = null;
emailInput.addEventListener("blur", () => {
const ok = isValidEmail(emailInput.value);
if (ok === lastState) return;
lastState = ok;
status.textContent = ok ? "Email looks good" : "Enter a valid email like name@example.com";
emailInput.setAttribute("aria-invalid", ok ? "false" : "true");
});
emailInput.addEventListener("focus", () => {
status.textContent = "";
});