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.

Async email validation state machine Diagram showing six states: IDLE, DEBOUNCING, VALIDATING, AVAILABLE, TAKEN, ERROR. IDLE transitions to DEBOUNCING on onChange. DEBOUNCING transitions to VALIDATING after 400ms debounce. VALIDATING transitions to AVAILABLE, TAKEN, or ERROR based on the server response. ERROR can transition back to VALIDATING on retry. IDLE DEBOUNCING VALIDATING AVAILABLE TAKEN ERROR onChange 400ms free in use failure retry

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

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

  2. Check the LRU cache. If the normalized email is already in cache, call setStatus(cached) and return immediately. The useEffect cleanup will not run because there is no timer or controller to clear.

  3. Set DEBOUNCING and start the timer. The setTimeout with debounceMs (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 calls clearTimeout(timerId) — the timer never fires.

  4. Instantiate a fresh AbortController for this cycle. Immediately call controllerRef.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.

  5. Increment cycleIdRef.current and capture thisCycleId. Even if two fetches are in flight simultaneously (edge case: abort signal arrives late), the commit guard if (thisCycleId !== cycleIdRef.current) return prevents the slower one from writing state.

  6. Set VALIDATING and call fetchAvailability. Pass controller.signal through to fetch. The function re-checks signal.aborted after every await — this catches the race where the abort fires between the fetch resolve and the res.json() call.

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

  8. On AbortError, return silently. These are not failures — they are deliberate cancellations. Surfacing them as errors confuses users.

  9. On other errors, retry with exponential backoff. Each retry increments retryIndex. After maxRetries attempts the hook sets ERROR and 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 when status === 'TAKEN'
  • Going offline triggers exponential-backoff retry, then surfaces ERROR after three failures
  • The same normalized email typed twice produces exactly one network request (cache hit confirmed in Network tab)
  • Component unmount during VALIDATING produces 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