Ch. 4 · Angular

Angular Async Validators and Pending State

A username-availability check traced step by step: PENDING status, cancelled checks, debouncing, errors that hang the form, and safe submit.

~6 min readintermediateupdated Oct 7, 2026

A sync validator answers immediately: is this field empty, is this an email address. An async validator answers later, usually after asking a server: is this username taken, is this coupon valid. That “later” introduces a third state that sync validation never has, and most async validation bugs come from code that ignores it.

This note builds a username-availability check for a sign-up form and traces exactly what the control does on each keystroke. The trace comes from @angular/forms 20.3 running in Node with a fake API, so statuses and timings are real. Angular 19’s reactive forms behave the same.

The validator

An async validator is a function that takes the control and returns an observable (or promise) of ValidationErrors | null:

export function usernameAvailable(api: UserApi): AsyncValidatorFn {
  return (control) =>
    timer(300).pipe(
      switchMap(() => api.isAvailable(control.value)),
      map((ok) => (ok ? null : { usernameTaken: true })),
      catchError(() => of({ availabilityUnknown: true })),
    );
}

username = new FormControl('', {
  validators: [Validators.required, Validators.minLength(3)],
  asyncValidators: [usernameAvailable(inject(UserApi))],
});
form = new FormGroup({ username: this.username });
TypeScript

Note inject() in the field initializer, not inside the validator function. The validator runs later, outside any injection context, so dependencies are captured when the component is created.

A keystroke-by-keystroke trace

The test typed “as”, then “ash”, then “asha” 100 ms later, waited, then changed it to “ashanti”. “asha” is taken in the fake API:

   0ms typed "as"      -> status=INVALID form.valid=false form.pending=false
   0ms typed "ash"     -> status=PENDING form.valid=false form.pending=true
 100ms validator for "ash" torn down
 100ms typed "asha"    -> status=PENDING form.valid=false form.pending=true
 410ms API called for "asha"
 410ms status INVALID      errors={"usernameTaken":true}
 510ms typed "ashanti" -> status=PENDING
 810ms API called for "ashanti"
 810ms status VALID
total API calls = 2
Text

Several behaviours in that trace are worth knowing well.

Sync validators gate async ones. “as” fails minLength(3), so the status is INVALID immediately and the async validator never runs. Angular doesn’t waste a request on a value that’s already wrong. If you see availability requests for one-character inputs, the sync validators are missing.

PENDING is its own state. As soon as “ash” passed the sync checks, the control went PENDING, and so did the form. While pending, valid is false and invalid is false. Code that checks if (form.invalid) return; before submitting will let a pending form through. Code that checks form.valid will block it. Know which you mean.

A new value cancels the old check. When “asha” arrived at 100 ms, Angular unsubscribed from the “ash” validator (“torn down”) before it had called the API. Angular always keeps only the latest async validation per control, like a switchMap.

That cancellation is what makes timer(300) a debounce. The validator waits 300 ms before calling the API. If another keystroke arrives in that window, the timer is cancelled with the rest of the observable and no request is made. Two API calls for four values. Without the timer, every keystroke past three characters would hit the server, and slow responses would be cancelled only on the client.

Showing the pending state

The template should reflect all three states, not just valid and invalid:

<input formControlName="username" />
@if (username.pending) {
  <small>Checking availability…</small>
} @else if (username.hasError('usernameTaken')) {
  <small class="error">That username is taken.</small>
} @else if (username.hasError('availabilityUnknown')) {
  <small class="warn">Couldn't check right now. We'll check again when you submit.</small>
}
<button [disabled]="form.invalid || form.pending">Create account</button>
HTML

Disabling the button while pending is good UX, but don’t rely on it alone. The Enter key in a form field submits too, and a (ngSubmit) handler should check status itself.

Two ways to hang a form forever

The validator’s observable must complete. Angular combines async validator results with forkJoin, which waits for completion. A validator that returns a BehaviorSubject, a store selector, or control.valueChanges emits a value but never completes, and the control stays PENDING forever. The test confirmed it: a validator returning new BehaviorSubject(null) left the status at PENDING. If you must return a long-lived source, add take(1).

It also must not error. A validator that let a 503 escape produced an uncaught error in the test, and the control stayed PENDING. With the catchError that maps failures to { availabilityUnknown: true }, the control became INVALID with that error, which is something the template can explain. Whether a failed check should block submission is a product decision. Mapping it to an error blocks it; mapping it to null lets the server decide at submit time.

Fewer checks with updateOn

Even with a debounce, validating while the user types can feel jumpy. updateOn: 'blur' runs all validators (and updates the value) only when the field loses focus:

username = new FormControl('', {
  validators: [Validators.required, Validators.minLength(3)],
  asyncValidators: [usernameAvailable(inject(UserApi))],
  updateOn: 'blur',
});
TypeScript

One request per blur instead of per pause. The trade-off is that the user doesn’t see feedback until they tab away, and form.value lags behind the input until then.

Submitting while a check is in flight

A user types quickly and presses Enter before the check finishes. The form is PENDING. You have two reasonable choices: refuse and show a message, or wait for the result and then decide. Waiting looks like this:

submit() {
  this.form.markAllAsTouched();
  const settled$ = this.form.pending
    ? this.form.statusChanges.pipe(filter((s) => s !== 'PENDING'), take(1))
    : of(this.form.status);

  settled$.subscribe((status) => {
    if (status === 'VALID') this.api.register(this.form.getRawValue()).subscribe();
  });
}
TypeScript

Whatever the client concludes, the server must enforce uniqueness again when the account is created. Two people can pass the availability check for the same name a second apart. The async validator is a courtesy that saves a round trip to an error; the unique constraint in the database is the real rule, and the submit handler needs to handle a “username taken” response even though the form said it was free.

Group-level async validators

Async validators can sit on a FormGroup too, for checks that depend on several fields, such as “is this postcode valid for this country”. The group goes PENDING while it runs. Remember that a value change in any child re-runs the group’s validators, so debounce matters even more there, and the sync-before-async rule applies at the group level too. Cross-field sync validation is covered in the note on cross-field validation.

Interview practice

A sign-up form’s “Create account” button uses [disabled]="form.invalid". QA reports that if they type a username and click immediately, the account is sometimes created with a name that’s already taken, and the server returns a 409 that the UI shows as a generic error. Separately, after the availability API had an outage, some users reported the form “spinning forever”. Explain both and fix them.

Answer

The first bug is the PENDING state. Right after typing, the async validator is still running, so the form is PENDING. A pending form has invalid === false, so [disabled]="form.invalid" leaves the button enabled, and the submit goes through before the check finishes. I’d disable on form.invalid || form.pending, and in the submit handler either return early while pending or wait for statusChanges to settle before sending. The 409 still has to be handled, because the server is the real authority: two users can claim the same name at the same moment, so the handler should map a 409 to setErrors({ usernameTaken: true }) on the control, which shows the proper message.

The “spinning forever” bug is the validator not handling errors. When the API failed, the error escaped the validator’s observable, so it never produced a result and the control stayed PENDING. I’d add catchError inside the validator that maps failures to something like { availabilityUnknown: true } (or null, if the team prefers to let the server decide) so the form always settles. While I’m there, I’d check that the validator completes (a take(1) if it reads from a long-lived source) and that it debounces with timer() so the outage isn’t made worse by a request per keystroke.

Further reading

More in Angular

read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
read ✓Angular · mid

Angular Template-Driven vs Reactive Forms

Both form styles build the same FormControl objects. What differs is who creates them and when, which changes timing, typing and testing.

~5 min readread →
esc