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.
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
- Network tab: Filter by
Fetch/XHR. Type a full address quickly and confirm superseded requests show(canceled)while only the final one completes — proof theAbortControllerand sequence guard are firing. - Cache: Re-enter a previously checked address; no new request should appear. Wait past the TTL and confirm the network call returns.
- Throttle: Set DevTools to
Fast 3Gand confirmaria-busytoggles totrueduring the wait and the spinner is announced politely. - 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.
Related Guides
- Asynchronous Server Checks — the architecture this recipe implements
- Cancelling Stale Async Validation with AbortController — the cancellation mechanics in depth
- Synchronous Validation Patterns — the format guard that runs before any fetch
- Cross-Field Validation Strategies — coordinate email with a confirmation field