Accessible forms and dynamic UI · 5 / 8
lesson 5

Async validation and live errors

Live validation is helpful — until it screams every keystroke. Politeness, debouncing, and knowing when to stay quiet.

~ 15 min read·lesson 5 of 8
0 / 8

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.

live-region.html
<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).

Watch out

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.

debounce.js
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.

on-blur.js
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.

async-check.js
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.

debounced"username taken" noisy"invalid""invalid""invalid""invalid""invalid"
Politely live: the user types, debounces 500ms, then a single 'username taken' is announced — not five.

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.

Tip

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:

  1. User types — no announcements.
  2. User leaves the field (blur) — validate, announce result politely.
  3. User comes back to fix it — clear the announcement, no re-announce on focus.
  4. 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.

blur-then-validate.js
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 = "";
});

Try it yourself

check your understanding
Which aria-live politeness setting is right for routine validation messages?
check your understanding
What's the main problem with validating on every input event?
check your understanding
Why use AbortController on async validation requests?
check your understanding
The user successfully fixes a username field. You should:
check your understanding
Best default trigger for live validation on a typical text input?
← prevnext lesson →
KeepLearningcertificate
for completing
Accessible forms and dynamic UI
0 of 8 read