Implementing Async Email Availability Checks

This recipe shows how to check whether an email address is already registered without blocking the user, flashing false errors, or leaking which addresses exist — using TypeScript, AbortController, a finite state machine, and a short-lived cache.

It is the concrete companion to Asynchronous Server Checks: that page establishes the debounce → abort → fetch → sequence-guard architecture; here we wire it to a single real endpoint and harden it for production.

When to Use This Recipe

Reach for an async availability check only when the answer genuinely lives on the server and matters before submission:

  • Use it for sign-up email/username uniqueness, where catching a collision early saves the user a full round-trip and a re-typed password.
  • Use it when the backend can answer in well under a second and you can debounce input to 300–500ms.
  • Skip it for format validation — that is a synchronous regex check that should run first via Synchronous Validation Patterns.
  • Skip it if revealing existence is a security concern you cannot mitigate; see the enumeration note in the edge cases below.
Email availability decision flow An email passes a synchronous format guard, then a cache lookup, then a debounced fetch that resolves to available, taken, or an unverifiable network fallback. format? cache? debounced fetch available already registered unverifiable (offline)
The cheapest checks gate the expensive ones: a failing format short-circuits, a cache hit skips the network, and only a fresh address reaches the debounced fetch.

The Minimal Working Implementation

The validator maps the lifecycle to the five-state machine from the parent guide, binds each fetch to a module-scoped AbortController, and short-circuits on a cache hit. Format validation happens first so the network is only ever asked about syntactically valid addresses.

export type ValidationState = 'idle' | 'pending' | 'resolved' | 'rejected' | 'cancelled';

export interface ValidationResult {
  available: boolean;
  message: string;
}

interface CacheEntry {
  result: ValidationResult;
  expiresAt: number;
}

const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export class AsyncEmailValidator {
  private state: ValidationState = 'idle';
  private controller: AbortController | null = null;
  private requestId = 0;
  private cache = new Map<string, CacheEntry>();
  private readonly TTL = 5 * 60 * 1000; // 5 minutes

  getState(): ValidationState { return this.state; }

  async check(rawEmail: string, debounceMs = 300): Promise<ValidationResult> {
    const email = rawEmail.trim().toLowerCase();

    // 1. Synchronous format guard — never spend a request on malformed input.
    if (!EMAIL_FORMAT.test(email)) {
      this.state = 'rejected';
      return { available: false, message: 'Enter a valid email address.' };
    }

    const id = ++this.requestId;
    this.state = 'pending';

    // 2. Debounce: wait out the keystroke burst.
    await new Promise<void>((resolve) => setTimeout(resolve, debounceMs));

    // 3. Sequence guard: a newer keystroke superseded this one.
    if (id !== this.requestId) {
      this.state = 'cancelled';
      return { available: false, message: '' };
    }

    // 4. Cache hit short-circuits the network entirely.
    const cached = this.getFromCache(email);
    if (cached) {
      this.state = 'resolved';
      return cached;
    }

    // 5. Abort the prior request and dispatch a fresh one.
    this.controller?.abort();
    this.controller = new AbortController();
    return this.fetchAvailability(email);
  }

  private async fetchAvailability(email: string): Promise<ValidationResult> {
    try {
      const response = await fetch(
        `/api/v1/email/availability?email=${encodeURIComponent(email)}`,
        { signal: this.controller?.signal, credentials: 'same-origin', headers: { Accept: 'application/json' } }
      );

      if (!response.ok) {
        this.state = 'rejected';
        return { available: false, message: this.mapServerError(response.status) };
      }

      const data = await response.json();
      const result: ValidationResult = {
        available: data.isAvailable,
        message: data.isAvailable ? 'Email is available.' : 'This email is already registered.',
      };
      this.cache.set(email, { result, expiresAt: Date.now() + this.TTL });
      this.state = 'resolved';
      return result;
    } catch (error) {
      if ((error as Error).name === 'AbortError') {
        this.state = 'cancelled';
        return { available: false, message: '' };
      }
      // Network failure: degrade gracefully rather than block the user.
      this.state = 'rejected';
      return { available: false, message: 'Unable to verify right now. Check your connection.' };
    }
  }

  private getFromCache(email: string): ValidationResult | null {
    const entry = this.cache.get(email);
    if (!entry) return null;
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(email);
      return null;
    }
    return entry.result;
  }

  private mapServerError(status: number): string {
    switch (status) {
      case 409: return 'This email is already registered.';
      case 422: return 'Invalid email format.';
      case 429: return 'Too many requests. Please wait a moment.';
      default:  return 'Server validation failed.';
    }
  }

  // Call from React useEffect cleanup, Vue onUnmounted, or Svelte onDestroy.
  cleanup(): void {
    this.controller?.abort();
    this.controller = null;
    this.cache.clear();
  }
}

Wire it to the field with the same accessible markup the parent guide uses — a polite live region for the status and a separate alert region for hard errors — so screen readers hear the verdict without losing the user’s place. The cancellation mechanics here are explored further in Cancelling Stale Async Validation with AbortController.

<div class="email-field-wrapper">
  <label for="email-input">Email address</label>
  <input
    id="email-input"
    type="email"
    autocomplete="email"
    aria-describedby="email-status"
    aria-invalid="false"
    aria-busy="false"
  />
  <div id="email-status" aria-live="polite" aria-atomic="true" class="status-message"></div>
</div>

Parameter & Option Reference

Option Type Default Effect
debounceMs number 300 Idle window before the fetch fires; raise toward 500 on busy backends
TTL number 300000 Cache lifetime in ms; balances freshness against redundant calls
credentials RequestCredentials 'same-origin' Match your cookie/session policy for the availability endpoint
requestId number Monotonic counter; the sequence guard compares against it
signal AbortSignal Cancels the prior in-flight request on each new keystroke

State-to-DOM Mapping

State aria-busy aria-invalid Live-region content Visual cue
idle false false (empty) Default border
pending true false “Checking availability…” Spinner + muted border
resolved (available) false false “Email is available.” Green check
rejected false true Error message Red border + icon
cancelled false false (unchanged) Neutral — never an error

Verifying It Works

  1. Network tab: Filter by Fetch/XHR. Type a full address quickly and confirm superseded requests show (canceled) while only the final one completes — proof the AbortController and sequence guard are firing.
  2. Cache: Re-enter a previously checked address; no new request should appear. Wait past the TTL and confirm the network call returns.
  3. Throttle: Set DevTools to Fast 3G and confirm aria-busy toggles to true during the wait and the spinner is announced politely.
  4. Playwright assertion:
import { test, expect } from '@playwright/test';

test('announces an available email', async ({ page }) => {
  await page.route('**/api/v1/email/availability*', (route) =>
    route.fulfill({ json: { isAvailable: true } })
  );
  await page.goto('/signup');
  await page.getByLabel('Email address').fill('new.user@example.com');
  await expect(page.locator('#email-status')).toHaveText('Email is available.');
  await expect(page.getByLabel('Email address')).toHaveAttribute('aria-invalid', 'false');
});

A fast unit test locks in the two behaviors most likely to regress — the sequence guard and the cache short-circuit:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AsyncEmailValidator } from './validator';

describe('AsyncEmailValidator', () => {
  beforeEach(() => vi.useFakeTimers());

  it('discards a superseded check as cancelled', async () => {
    const validator = new AsyncEmailValidator();
    const first = validator.check('a@example.com');
    validator.check('b@example.com'); // bumps requestId before the first debounce ends
    vi.advanceTimersByTime(300);
    await expect(first).resolves.toMatchObject({ message: '' }); // silent, not an error
  });

  it('serves a cached verdict without a second request', async () => {
    const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ isAvailable: true }), { status: 200 })
    );
    const validator = new AsyncEmailValidator();
    await validator.check('cached@example.com');
    vi.advanceTimersByTime(300);
    await validator.check('cached@example.com');
    vi.advanceTimersByTime(300);
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });
});

Edge Cases & Failure Modes

1. Account enumeration. A precise “already registered” message tells an attacker which emails have accounts. Mitigate by rate-limiting the endpoint per IP, requiring the check to be behind a token on sensitive flows, or returning a generic verdict and confirming registration over email instead. The recipe’s 429 handling is the first line of defense.

2. Offline / unverifiable. When the fetch throws a non-abort error, the validator returns an “Unable to verify” message and leaves aria-invalid="false" — the user is not punished for a flaky network. Re-run the check on the online event and never let an unverifiable result silently block submission.

3. Trailing whitespace and casing. Browsers and password managers happily paste " User@Example.com ". Normalizing with trim().toLowerCase() before both the cache key and the request prevents two representations of the same address from each costing a round-trip and producing inconsistent verdicts.

Frequently Asked Questions

Should the availability check ever block form submission on its own?

Only when it returned a confident rejected for a taken address. An "unable to verify" network fallback must not block — the authoritative duplicate-key check still happens server-side on submit. Treat the client check as a fast hint, not the gate.

How long should the cache TTL be?

A few minutes is plenty for a single sign-up session — long enough to skip redundant calls while the user edits other fields, short enough that a freshly registered address won't read as available for long. Invalidate explicitly on submit so the final verdict is always fresh.

Why normalize the email before caching and fetching?

Pasted input often carries leading/trailing whitespace and inconsistent casing. trim().toLowerCase() collapses those variants into one cache key and one request, so User@x.com and user@x.com don't each trigger a separate round-trip or a contradictory verdict.

← Back to Asynchronous Server Checks