The textbook Angular async validator — return this.http.get(...).pipe(map(...)) — fires a request on every keystroke, lets slow responses for old values overwrite newer ones in some setups, and leaves the control stuck in PENDING forever if the observable never completes, which silently disables submit.
Angular does part of the work for you: when a control’s value changes, it unsubscribes from the previous async validator’s observable, which cancels an in-flight HttpClient request. What it does not do is debounce, guarantee completion, or handle errors. This page writes an async validator that does all three, for use within the Angular reactive forms adapters architecture.
Context and prerequisites
How Angular runs async validators:
- Sync validators run first. If any sync validator fails, async validators do not run — no request for an obviously malformed email.
- If sync validation passes, the control’s status becomes
PENDINGand Angular subscribes to eachAsyncValidatorFn’s observable. - Angular takes the first emitted value as the result. The control stays
PENDINGuntil the observable emits (and it must complete for some flows, such asstatusChangesconsumers waiting on completion). - On the next value change, Angular unsubscribes from the previous observable before starting a new one. For
HttpClient, unsubscribing aborts the XHR.
So cancellation of in-flight requests is automatic; debouncing is not, because a new validator invocation starts immediately on each change. The standard technique is to debounce inside the validator with timer, so an unsubscribed invocation never reaches its HTTP call.
The core pattern: timer, switchMap, first, catchError
import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from "@angular/forms";
import { Observable, of, timer } from "rxjs";
import { catchError, first, map, switchMap } from "rxjs/operators";
@Injectable({ providedIn: "root" })
export class EmailAvailability {
private http = inject(HttpClient);
validator(debounceMs = 400): AsyncValidatorFn {
return (control: AbstractControl<string>): Observable<ValidationErrors | null> => {
const value = (control.value ?? "").trim().toLowerCase();
if (!value) return of(null); // "required" is a sync validator's job
// timer() delays the request. If the value changes during the delay,
// Angular unsubscribes from THIS observable, so the HTTP call never starts.
return timer(debounceMs).pipe(
// switchMap: if timer emitted, start the request. Unsubscribing later
// (next keystroke) cancels the in-flight HttpClient request (XHR abort).
switchMap(() =>
this.http.get<{ available: boolean }>("/api/email-available", { params: { email: value } }),
),
map((res) => (res.available ? null : { emailTaken: true })),
// Network failure must not block the form: report a soft, retryable error
// (or null, if the server will re-check on submit anyway).
catchError(() => of({ availabilityUnknown: true })),
// Guarantee completion after the first result, so PENDING always ends.
first(),
);
};
}
}
// Usage: sync validators first, async validator second, optionally updateOn: "blur".
email = new FormControl("", {
nonNullable: true,
validators: [Validators.required, Validators.email],
asyncValidators: [inject(EmailAvailability).validator()],
updateOn: "change", // or "blur" to validate only when the user leaves
});
Step-by-step walkthrough
- Rely on sync validators to gate the request. Put
requiredandemailinvalidators; Angular skips async validators while they fail, so malformed input never hits the network. - Debounce with
timerinside the validator. Each keystroke starts a new validator; the previous one is unsubscribed during its timer, so its request is never sent. - Start the request with
switchMap. If the value changes after the request starts, Angular’s unsubscribe cancels it — the same effect as cancelling stale async validation with AbortController. - Catch errors into a soft result. A thrown error leaves the control’s status in an inconsistent state; map failures to
{ availabilityUnknown: true }and let the UI say “We couldn’t check this right now”. - End with
first(). The control leavesPENDINGonly when the observable emits;first()makes the contract explicit and completes the stream. - Show the pending state accessibly.
control.pendingdrives a visible “Checking…” with a polite announcement, as described in accessible pending state for async validation.
Why debouncing belongs inside the validator
It is natural to try debouncing valueChanges and calling updateValueAndValidity yourself, or to use debounceTime inside the validator’s pipe. Neither works cleanly. Angular calls the validator function once per value change and subscribes to a new observable each time, so an operator like debounceTime inside one invocation only ever sees that invocation’s single value and debounces nothing. The debounce must be expressed as a delay before the work starts — timer — combined with Angular’s own unsubscribe-on-change, which cancels every delayed invocation except the last. The result is a debounce built from two cooperating mechanisms, which is why it is worth wrapping in a reusable factory rather than rewriting per field.
Failure modes and edge cases
1. Form-level PENDING blocks submit
form.valid is false while any control is PENDING. If a submit button is disabled on !form.valid, a slow availability check disables submission. On submit, if form.pending, wait for statusChanges to leave PENDING (with a timeout) rather than rejecting the submit.
2. Async validators re-run on unrelated updates
form.updateValueAndValidity() or patchValue on the whole form re-runs every validator, including async ones. Pass { emitEvent: false } where appropriate, or skip the request when the value equals the last checked value (cache it in the service, as in caching async validation results).
3. updateOn: "blur" and the final keystroke
With updateOn: "blur", the control’s value (and validation) updates only on blur. Pressing Enter to submit without blurring uses the old value on some flows; call form.updateValueAndValidity() at submit or use updateOn: "submit" for the final check.
4. Rate limits
Even debounced validators can be rate-limited by the server. Treat 429 as “unknown” rather than “taken”, as in handling timeouts and 429s in async validators.
5. Server re-check on submit
An availability pre-check is advisory; two users can pick the same email between check and submit. The submit endpoint must enforce uniqueness and return a field error the form maps back onto the control with setErrors.
Verification checklist
Frequently Asked Questions
Why not debounce valueChanges and set errors manually?
You can, but you then bypass Angular’s status model: the control is not PENDING during the check, form.valid is wrong until your subscription sets errors, and you must manage subscriptions and cancellation yourself. An AsyncValidatorFn integrates with status, submit and markAllAsTouched for free.
Should the validator return an error when the network fails?
Return a distinct, soft error such as availabilityUnknown that your UI shows as a warning and your submit logic allows through, since the server re-checks on submit. Returning emailTaken on failure blames the user for an outage.
Does this work with signals-based forms?
The observable pattern is specific to reactive forms. With signal-first form APIs, the same ideas apply — a delay before starting, cancellation on change, guaranteed completion — expressed with the APIs those forms provide for async validation. The bridging approach is covered in bridging Angular signals and reactive forms.