Writing an Angular Async Validator That Calls an HTTP Endpoint

An Angular reactive form control that must verify a value against your backend — a unique username, an unused email, a valid coupon — needs an AsyncValidatorFn that debounces keystrokes, cancels stale requests, and still funnels its verdict into the native Constraint Validation API so submission stays gated. This page builds that validator end to end.

When to Use This HTTP-Backed Async Approach

Reach for an AsyncValidatorFn only when the answer genuinely lives on the server. If a rule can be decided in the browser — length, pattern, cross-field equality — keep it synchronous; see the Angular Cross-Field Password-Match Validator for that class of check. The general theory of round-tripping to a backend, independent of framework, is covered under asynchronous server checks.

Use this approach when:

  • The verdict requires data only the server holds (uniqueness, availability, entitlement).
  • You can tolerate a short debounce window before the control resolves.
  • You want the pending state to block submission without freezing the UI.

Prefer a synchronous validator — or a resolver-driven schema like Zod schema validation — when the rule is deterministic on the client.

Async validator request lifecycle A control value is debounced, sent to an HTTP endpoint with cancellation, then mapped to a native validity state. valueChanges → HTTP verdict → setCustomValidity Debounce Input timer + distinctUntilChanged HTTP Check switchMap cancels stale Validity Verdict null or ValidationErrors
Each keystroke is debounced, the in-flight HTTP check is cancelled when superseded, and only the final response sets the control's validity.

A switchMap-Based AsyncValidatorFn Wired to reportValidity()

The validator below returns an Observable<ValidationErrors | null>. A switchMap guarantees that only the latest request survives, timer debounces the burst of keystrokes, and first() completes the stream so Angular can settle the PENDING status. The template ships <form novalidate> and submission is gated by a single manual checkValidity() / reportValidity() call against the native input, keeping the baseline pattern this site standardises on.

import { Component, inject } from '@angular/core';
import {
  AbstractControl,
  AsyncValidatorFn,
  ReactiveFormsModule,
  ValidationErrors,
  Validators,
  FormBuilder,
} from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError, first } from 'rxjs/operators';

// Factory returns an AsyncValidatorFn so the endpoint + debounce are configurable.
export function usernameAvailableValidator(
  http: HttpClient,
  debounceMs = 350,
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = (control.value ?? '').trim();
    if (!value) return of(null); // empty is a job for Validators.required

    // timer(debounceMs) delays the request; switchMap aborts any prior in-flight call.
    return timer(debounceMs).pipe(
      switchMap(() =>
        http
          .get<{ available: boolean }>('/api/username-check', { params: { value } })
          .pipe(
            map((res) => (res.available ? null : { usernameTaken: true })),
            // Network failure must NOT read as "valid"; surface a distinct error.
            catchError(() => of({ usernameCheckFailed: true })),
          ),
      ),
      first(), // complete the stream so control status leaves PENDING
    );
  };
}

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form novalidate (submit)="onSubmit($event)">
      <label for="username">Username</label>
      <input id="username" #usernameEl formControlName="username" />
      <button type="submit">Create account</button>
    </form>
  `,
})
export class SignupComponent {
  private fb = inject(FormBuilder);
  private http = inject(HttpClient);

  form = this.fb.group({
    username: [
      '',
      { validators: [Validators.required, Validators.minLength(3)],
        asyncValidators: [usernameAvailableValidator(this.http)],
        updateOn: 'change' },
    ],
  });

  onSubmit(event: Event) {
    event.preventDefault();
    const el = (event.target as HTMLFormElement)
      .querySelector<HTMLInputElement>('#username')!;

    // Bridge Angular's async verdict into the native Constraint Validation API.
    if (this.form.controls.username.pending) {
      el.setCustomValidity('Still checking availability…');
    } else if (this.form.controls.username.hasError('usernameTaken')) {
      el.setCustomValidity('That username is already taken.');
    } else if (this.form.controls.username.hasError('usernameCheckFailed')) {
      el.setCustomValidity('Could not verify username. Try again.');
    } else {
      el.setCustomValidity(''); // '' clears the error → control is valid
    }

    // One manual gate: native UI shows the message and blocks submit.
    if (!el.reportValidity() || this.form.invalid) return;

    // ...proceed with the verified payload
  }
}

Mapping the Angular error key onto setCustomValidity() is what lets you reuse custom validity messages instead of maintaining a parallel error channel.

AsyncValidatorFn Configuration Reference

These are the knobs that change behaviour for this recipe.

Option Type Default Purpose
debounceMs number 350 Delay before the HTTP call fires, collapsing keystroke bursts.
updateOn 'change' | 'blur' | 'submit' 'change' When the control revalidates; 'blur' cuts request volume sharply.
asyncValidators AsyncValidatorFn[] [] Registered async checks; all must resolve before status leaves PENDING.
endpoint path string /api/username-check The URL the factory queries; inject per environment.
error key string usernameTaken The ValidationErrors key mapped to a setCustomValidity message.
catchError fallback ValidationErrors { usernameCheckFailed: true } Fail-closed verdict when the network errors.

Verification Steps

import { test, expect } from '@playwright/test';

test('taken username blocks submit', async ({ page }) => {
  await page.route('**/api/username-check*', (route) =>
    route.fulfill({ json: { available: false } }),
  );
  await page.goto('/signup');
  await page.fill('#username', 'admin');
  await page.click('button[type=submit]');
  // Native validation bubble keeps us on the page.
  await expect(page.locator('#username')).toBeFocused();
});

Edge Cases & Failure Modes

Stale response overwrites the current value. Without switchMap, a slow early request can resolve after a newer one and stamp the wrong verdict. switchMap unsubscribes the prior inner observable so the HTTP call is cancelled; never substitute mergeMap here.

Network error read as valid. If catchError returns of(null), a dropped connection silently marks the field valid and lets a duplicate through. Fail closed with a distinct error key so the submit gate holds, mirroring the resilience guidance in asynchronous server checks.

Pending status never resolves. If the inner observable never completes, the control is stuck in PENDING and reportValidity() will keep blocking. Terminate the stream with first() (or take(1)) so Angular can transition the status, and pair the state with accessible error messaging so screen-reader users hear the change.

Caching Verdicts to Kill Duplicate Requests

The factory above fires a fresh HTTP call every time the control revalidates, even when the user deletes a character and retypes the same value. For a uniqueness check that answer is stable for the lifetime of the form, so re-querying admin five times is pure waste. A small Map cache keyed on the trimmed value collapses those repeats, and of(...) returns the memoised verdict synchronously — Angular still transitions through PENDING for one microtask, but no socket is opened.

export function cachedUsernameValidator(
  http: HttpClient,
  debounceMs = 350,
): AsyncValidatorFn {
  // Cache lives in the closure, so it is scoped to this validator instance.
  const cache = new Map<string, ValidationErrors | null>();

  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = (control.value ?? '').trim();
    if (!value) return of(null);
    if (cache.has(value)) return of(cache.get(value)!); // no network hit

    return timer(debounceMs).pipe(
      switchMap(() =>
        http.get<{ available: boolean }>('/api/username-check', { params: { value } }).pipe(
          map((res) => (res.available ? null : { usernameTaken: true })),
          // Only cache authoritative answers — never cache a network failure.
          tap((verdict) => cache.set(value, verdict)),
          catchError(() => of({ usernameCheckFailed: true })),
        ),
      ),
      first(),
    );
  };
}

Note the ordering: tap sits before catchError, so a usernameCheckFailed verdict from a dropped connection is never written to the cache. Caching a failure would pin the field to an error even after connectivity returns. If your backend can change its answer mid-session — a username freed by another user’s account deletion — cap the cache with a short TTL or clear it on form reset rather than trusting it indefinitely.

Composing Two Async Validators Without Serialising Them

Registering an array of asyncValidators on a control runs them in parallel and merges their ValidationErrors objects; the status stays PENDING until every one completes. That is the right default — an email field might check both format-normalisation on the server and disposable-domain blocklisting, and you want both verdicts at once, not one after the other. The merged error map means a single control can carry { usernameTaken: true, reservedWord: true } simultaneously, so your setCustomValidity() bridge should test keys in priority order and surface the most actionable message first.

form = this.fb.group({
  username: [
    '',
    {
      validators: [Validators.required, Validators.minLength(3)],
      // Both run concurrently; errors are shallow-merged into one map.
      asyncValidators: [
        cachedUsernameValidator(this.http),
        reservedWordValidator(this.http),
      ],
      updateOn: 'blur', // one revalidation per edit session, not per keystroke
    },
  ],
});

Because the merge is a shallow object spread, two validators that emit the same key will clobber each other — give every check a distinct key (usernameTaken, reservedWord, usernameCheckFailed) so no verdict is silently lost. If you genuinely need sequential checks — skip the expensive availability lookup unless a cheaper format check passes — do not chain them at registration time; compose them inside one AsyncValidatorFn with a switchMap that short-circuits, so the network call is skipped rather than merely ignored.

Why updateOn: 'blur' Changes the Submit Race

Switching a control to updateOn: 'blur' has a subtle consequence for the submit gate. With 'change', the async validator has usually settled by the time the user reaches the button. With 'blur', tabbing straight from the field to the submit button fires the blur-triggered validation and the submit handler in the same tick, so this.form.controls.username.pending is frequently true at the moment you read it. The setCustomValidity('Still checking availability…') branch in onSubmit is therefore not an edge case under 'blur' — it is the common path. Keep that branch, and consider awaiting form.statusChanges filtered to a settled status before proceeding rather than rejecting outright, so a legitimate user is not bounced for being fast.

Frequently Asked Questions

Why does my async validator leave the control stuck in PENDING?

Angular keeps the status PENDING until the returned observable completes. An HTTP call from HttpClient completes on its own, but if you build the stream with operators like a bare interval or forget to terminate a timer chain, it never completes. Add first() or take(1) as the final operator so the stream emits once and closes.

Should I use switchMap or debounceTime for the debounce?

Inside an AsyncValidatorFn the function is invoked fresh per change, so there is no long-lived stream for debounceTime to operate on. Use timer(ms) to introduce the delay and switchMap to cancel superseded requests. Reserve debounceTime for validators wired to a persistent valueChanges subscription.

How do I stop the HTTP request from firing on every keystroke?

Set updateOn: 'blur' on the control so the async validator only runs when the field loses focus, or raise debounceMs. On blur, a single request fires per edit session, which is usually the right trade-off for uniqueness checks where per-keystroke feedback is unnecessary.

Where should I store the cache so it survives re-renders but not stale data?

Keep the Map in the factory's closure, as shown, so it is created once when the validator is registered and shared across every revalidation of that control. Do not hoist it to module scope — that leaks verdicts between separate form instances and across users in a server-rendered context. Clear it on form.reset(), or attach a timestamp per entry and evict anything older than your tolerance for stale availability answers.

Does registering multiple asyncValidators run them one after another?

No. Angular composes an array of async validators with forkJoin semantics: they all start together and the control leaves PENDING only once the slowest completes. Their error objects are shallow-merged, so give each validator a unique ValidationErrors key. If you need one check to gate another, combine them inside a single AsyncValidatorFn using switchMap instead of registering two.

← Back to Angular Reactive Forms Validation