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:

  1. Sync validators run first. If any sync validator fails, async validators do not run — no request for an obviously malformed email.
  2. If sync validation passes, the control’s status becomes PENDING and Angular subscribes to each AsyncValidatorFn’s observable.
  3. Angular takes the first emitted value as the result. The control stays PENDING until the observable emits (and it must complete for some flows, such as statusChanges consumers waiting on completion).
  4. 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.

Typing "ada@ex.io" with three validator designs The naive validator sends a request for every keystroke that passes sync validation, and all but the last are cancelled mid-flight. The timer-debounced validator waits 400 milliseconds after the last keystroke and sends one request. The updateOn blur configuration sends one request when the user leaves the field, at about 2.6 seconds. naive (per keystroke) 5 requests, 4 cancelled timer(400) + switchMap 1 request after pause updateOn blur 1 request on blur 0ms 500ms 1000ms 1500ms 2000ms 2500ms 3000ms

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

  1. Rely on sync validators to gate the request. Put required and email in validators; Angular skips async validators while they fail, so malformed input never hits the network.
  2. Debounce with timer inside the validator. Each keystroke starts a new validator; the previous one is unsubscribed during its timer, so its request is never sent.
  3. 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.
  4. 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”.
  5. End with first(). The control leaves PENDING only when the observable emits; first() makes the contract explicit and completes the stream.
  6. Show the pending state accessibly. control.pending drives 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.

Validator pipelines and their behaviour A bare HTTP call sends one request per keystroke, is cancelled by Angular on change, and can stay pending if the request errors without handling. Adding debounceTime inside the pipe does not debounce because each invocation sees one value. timer plus switchMap sends one request after a pause and is cancellation safe. Adding catchError and first guarantees the control always leaves PENDING. Pipeline Requests per word Stuck PENDING? http.get(...) one per keystroke on unhandled error debounceTime(400) + http still one per keystroke on unhandled error timer(400) + switchMap(http) one after the pause on unhandled error catchError + first() one after the pause never

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.

Control status through one async check The user types a well-formed email and sync validators pass. The control's status becomes PENDING and the async validator starts its 400 millisecond timer. When the timer fires, switchMap starts the HTTP request. The server responds that the email is not available. The validator emits emailTaken and completes via first, and the control's status becomes INVALID with the emailTaken error. User FormControl Async validator Server value passes sync validators status PENDING; subscribe after 400 ms: GET /email-available { available: false } emit { emailTaken } + complete

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.


Related

← Angular Reactive Forms Adapters