Exact problem: a stale TAKEN response from a slow network hop overwrites the AVAILABLE result that arrived a moment later, silently blocking a valid registration — because there is no request-ID guard on the state commit.
Fixing this requires four tightly coordinated pieces: a debounced fetch, an AbortController that cancels in-flight requests on every new keystroke, a request-ID guard that rejects out-of-order responses, and an LRU cache that short-circuits identical lookups. This page walks through each piece, then covers failure modes and the ARIA wiring your screen-reader users depend on.
Context and prerequisites
This page is a focused how-to that sits under Asynchronous Validation Strategies, which covers the full debounce-and-cancel lifecycle model that the hook below implements. If you are deciding whether to colocate this logic inside a Zod .superRefine() call or keep it separate, read Integrating Zod for Schema Validation first — async Zod refinements carry different abort semantics than the manual pattern shown here.
State machine diagram
The hook moves through six states. Understanding these transitions is the fastest way to diagnose production incidents.
Core implementation
The hook below is the complete, production-ready implementation. Every non-obvious line carries an inline comment.
import { useEffect, useRef, useState } from 'react';
type ValidationState =
| 'IDLE'
| 'DEBOUNCING'
| 'VALIDATING'
| 'AVAILABLE'
| 'TAKEN'
| 'ERROR';
interface UseAsyncEmailAvailabilityReturn {
status: ValidationState;
error: Error | null;
}
// Module-level LRU cache shared across hook instances.
// Keyed by normalized (lowercase + trimmed) email so equivalent addresses
// never trigger duplicate network calls within the same page session.
const cache = new Map<string, ValidationState>();
const MAX_CACHE_SIZE = 50;
function evictIfFull(): void {
if (cache.size >= MAX_CACHE_SIZE) {
// Map preserves insertion order — delete the oldest key.
const firstKey = cache.keys().next().value;
if (firstKey !== undefined) cache.delete(firstKey);
}
}
async function fetchAvailability(
normalized: string,
signal: AbortSignal // AbortSignal threads the cancellation token into fetch;
// if the controller fires, fetch rejects with AbortError.
): Promise<ValidationState> {
const res = await fetch(
`/api/validate-email?email=${encodeURIComponent(normalized)}`,
{ signal }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Re-check abort state after every await — the signal can fire between
// the fetch resolve and this line if the user typed again very quickly.
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
const data = (await res.json()) as { isAvailable: boolean };
return data.isAvailable ? 'AVAILABLE' : 'TAKEN';
}
export function useAsyncEmailAvailability(
email: string,
{ debounceMs = 400, maxRetries = 3 } = {}
): UseAsyncEmailAvailabilityReturn {
const [status, setStatus] = useState<ValidationState>('IDLE');
const [error, setError] = useState<Error | null>(null);
// useRef holds the AbortController so the cleanup function always closes
// over the *current* controller, not the one captured at render time.
const controllerRef = useRef<AbortController | null>(null);
const retryCountRef = useRef(0);
const cycleIdRef = useRef(0); // monotonically incrementing request-ID guard
useEffect(() => {
const trimmed = email.trim();
if (!trimmed) {
setStatus('IDLE');
return;
}
const normalized = trimmed.toLowerCase();
// Fast path: return the cached result without touching the network.
const cached = cache.get(normalized);
if (cached) {
setStatus(cached);
return;
}
setStatus('DEBOUNCING');
retryCountRef.current = 0;
const timerId = setTimeout(async () => {
// Cancel any previous in-flight request immediately.
// Calling .abort() on an already-aborted controller is a no-op.
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
// Increment the cycle ID. The async callback will reject its own
// result if this counter has moved on by the time it resolves.
const thisCycleId = ++cycleIdRef.current;
const attempt = async (retryIndex: number): Promise<void> => {
try {
setStatus('VALIDATING');
const result = await fetchAvailability(normalized, controller.signal);
// Guard: only commit state if this cycle is still the latest one.
if (thisCycleId !== cycleIdRef.current) return;
evictIfFull();
cache.set(normalized, result);
setStatus(result);
setError(null);
} catch (err) {
if ((err as DOMException).name === 'AbortError') return; // user moved on — discard silently
if (thisCycleId !== cycleIdRef.current) return;
if (retryIndex < maxRetries) {
// Exponential backoff: 500ms → 1000ms → 2000ms
const delay = 500 * Math.pow(2, retryIndex);
setTimeout(() => attempt(retryIndex + 1), delay);
} else {
setStatus('ERROR');
setError(err instanceof Error ? err : new Error(String(err)));
}
}
};
await attempt(0);
}, debounceMs);
// Cleanup: cancel the debounce timer and abort the in-flight request
// when email changes before the timer fires, or on unmount.
return () => {
clearTimeout(timerId);
controllerRef.current?.abort();
};
}, [email, debounceMs, maxRetries]);
return { status, error };
}
Step-by-step walkthrough
-
Normalize the email first.
email.trim().toLowerCase()before any cache lookup or network call ensures[email protected]and[email protected]share the same cached result and never generate duplicate requests. -
Check the LRU cache. If the normalized email is already in
cache, callsetStatus(cached)and return immediately. TheuseEffectcleanup will not run because there is no timer or controller to clear. -
Set
DEBOUNCINGand start the timer. ThesetTimeoutwithdebounceMs(default 400 ms) delays the actual fetch. Any new keystroke before the timer fires will cause React to re-run the effect, which will call the cleanup, which callsclearTimeout(timerId)— the timer never fires. -
Instantiate a fresh
AbortControllerfor this cycle. Immediately callcontrollerRef.current?.abort()on the previous controller before assigning the new one. This pattern is the single most important line in the hook: it ensures that a slow response from a previous cycle cannot overwrite the current state. -
Increment
cycleIdRef.currentand capturethisCycleId. Even if two fetches are in flight simultaneously (edge case: abort signal arrives late), the commit guardif (thisCycleId !== cycleIdRef.current) returnprevents the slower one from writing state. -
Set
VALIDATINGand callfetchAvailability. Passcontroller.signalthrough tofetch. The function re-checkssignal.abortedafter everyawait— this catches the race where the abort fires between thefetchresolve and theres.json()call. -
On success, write to cache and commit state.
evictIfFull()prevents unbounded memory growth by removing the oldest entry when the 50-item limit is reached. -
On
AbortError, return silently. These are not failures — they are deliberate cancellations. Surfacing them as errors confuses users. -
On other errors, retry with exponential backoff. Each retry increments
retryIndex. AftermaxRetriesattempts the hook setsERRORand surfaces the underlying error object.
Failure modes and edge cases
Stale TAKEN overwrites AVAILABLE
This is the race condition the cycle-ID guard prevents. If you see a free address briefly flash as taken during rapid typing, check that thisCycleId !== cycleIdRef.current is evaluated before setStatus is called, not after.
Autofill bypasses debounce
Browser autofill fires a single synthetic change event (not a stream of input events), so the debounce will fire exactly once. No special handling needed. However, some password managers dispatch input followed immediately by change — confirm in DevTools that both events are handled by the same controlled input so the hook sees only the final settled value.
Safari AbortError vs DOMException naming
Older Safari versions throw DOMException with .name === 'AbortError' but .message as an empty string. The guard (err as DOMException).name === 'AbortError' is safe across all current browsers; do not rely on err instanceof DOMException alone.
Component unmounts while VALIDATING
The useEffect cleanup calls controllerRef.current?.abort(). This aborts the in-flight fetch, which rejects with AbortError, which is silently discarded. No state updates fire after unmount.
Cache serves stale TAKEN for a reclaimed email
If a user abandons registration and the email becomes free again, the module-level cache will serve the old TAKEN result until the page is refreshed. For high-churn sign-up flows, add a timestamp to each cache entry and expire results older than a threshold (e.g., 5 minutes).
ARIA live-region wiring
Wire the six states to ARIA attributes so screen readers announce status changes without interrupting typing. Use a separate status <span> — never aria-live on the input itself.
export function EmailField() {
const [email, setEmail] = React.useState('');
const { status, error } = useAsyncEmailAvailability(email);
const statusMessage: Record<ValidationState, string> = {
IDLE: '',
DEBOUNCING: '',
VALIDATING: 'Checking availability…',
AVAILABLE: 'Email address is available.',
TAKEN: 'This email is already registered.',
ERROR: 'Could not verify availability. Please try again.',
};
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-invalid={status === 'TAKEN' || status === 'ERROR'}
aria-describedby="email-status"
data-validation-state={status} {/* Playwright/Cypress selector hook */}
/>
{/* aria-live="polite" announces after the user pauses typing.
role="status" is equivalent but polite is more widely supported. */}
<span
id="email-status"
aria-live="polite"
aria-atomic="true"
style={{ display: 'block', minHeight: '1.2em' }}
>
{statusMessage[status]}
</span>
</div>
);
}
State-to-ARIA mapping summary:
| State | aria-invalid | aria-live | Notes |
|---|---|---|---|
IDLE / DEBOUNCING |
— | polite | Do not announce anything while the user is typing |
VALIDATING |
— | polite | “Checking availability…” — brief, non-intrusive |
AVAILABLE |
false |
polite | Positive confirmation read once |
TAKEN |
true |
polite | Error wired via aria-describedby to the status span |
ERROR |
true |
polite | Include a retry affordance with a descriptive aria-label |
Verification checklist
- Network tab shows only one outbound request after rapid typing (10+ characters per second)
- Aborting a slow request (throttle to Slow 3G, type quickly) does not cause a “TAKEN” flash on a free email
- Submitting while
status === 'VALIDATING'blocks the form and waits for resolution - Screen reader announces “Email address is available” only once after the user pauses — not on every keystroke
aria-invalid="true"appears on the input whenstatus === 'TAKEN'- Going offline triggers exponential-backoff retry, then surfaces
ERRORafter three failures - The same normalized email typed twice produces exactly one network request (cache hit confirmed in Network tab)
- Component unmount during
VALIDATINGproduces no React “state update on unmounted component” warning
FAQ
Why not validate on every keystroke without debounce?
Unthrottled requests saturate the network, produce race conditions, and degrade server performance. A 400 ms debounce aligns with average typing cadence while ensuring the settled input value is what gets validated. You can reduce the delay to 250 ms for fields where users typically paste an email rather than type it.
What happens if the user submits while status is VALIDATING?
The onSubmit handler must be a blocking gate. The cleanest approach is to surface the pending state as a form-level submitting guard: if status !== 'AVAILABLE', call event.preventDefault() and display a “Verifying email…” banner. If you use React Hook Form, pass the status into a custom validate function on the field that returns a promise — React Hook Form will await it before running handleSubmit.
How do I handle syntactically valid but RFC 5322-violating emails?
Run a synchronous regex check before the hook fires. If it fails, short-circuit to ERROR immediately and skip the network call. The hook itself can accept an optional skipNetwork flag, or you can gate the email prop: only pass a non-empty string to the hook once the local format check passes.
How do I verify the LRU cache is working during QA?
In a development build, temporarily add (window as any).__emailCache = cache after the cache declaration. Open the console, type a valid email, wait for AVAILABLE, then type the same email again. Confirm in the Network tab that no second request fires, and window.__emailCache.get('[email protected]') returns 'AVAILABLE'.
Related
- Asynchronous Validation Strategies — debounce architecture, cancel tokens, and retry coordination patterns
- Integrating Zod for Schema Validation — async
.superRefine()and how Zod’s abort semantics differ from manual AbortController usage - Form Validation Lifecycle — how async validators plug into the full onChange → onBlur → onSubmit pipeline
- Error State Mapping Patterns — propagating
TAKENandERRORstates through to accessible UI components