skip to content

An Angular sign-up form's username-availability validator sends a request on every keystroke and sometimes stays PENDING forever; how do you diagnose and fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. default update timing is every change
  2. a control-level updateOn option
  3. Angular unsubscribes the previous check
  4. timer inside, EMPTY is a trap

basics

~20 s

Async validators run on every value change by default. Set updateOn: 'blur' on the control, or debounce inside the validator with timer() because Angular cancels the previous check, and make sure every path emits a result and completes.

solid answer

~40 s

By default a control's `updateOn` is `'change'`, so the async validator runs on every keystroke that passes the sync validators. Two fixes, often combined: set `updateOn: 'blur'` on the username control, so its value and validation update only when the field loses focus; or debounce inside the validator by returning `timer(400).pipe(switchMap(() => http.get(...)), ...)`. The debounce works because Angular unsubscribes from the previous validation on each change, so a superseded timer never fires its request. The stuck `PENDING` usually comes from a path that never emits: `catchError(() => EMPTY)` completes silently, a `filter` drops the value, or a never-completing stream sits in an `asyncValidators` array, which Angular combines through `forkJoin`. Map every outcome to `null` or an error object, and keep the server as the authority for uniqueness.

code

ts · 26 lines
ts
import {Component, inject} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms';
import {usernameAvailable} from './username-available';

@Component({
  selector: 'app-username-field',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="username" />
    @if (username.pending) {
      <p>Checking availability...</p>
    } @else if (username.hasError('usernameTaken')) {
      <p>That username is taken.</p>
    }
  `,
})
export class UsernameField {
  private readonly http = inject(HttpClient);
  readonly username = new FormControl('', {
    nonNullable: true,
    validators: [Validators.required, Validators.minLength(3)],
    asyncValidators: [usernameAvailable(this.http)],
    updateOn: 'blur',
  });
}

go deeper

for a junior

Recall that validators run on every change by default and that updateOn: 'blur' makes a control update and validate when it loses focus.

for a middle

Explain why debouncing must happen inside the validator and how Angular's unsubscription of the previous check makes a timer-based debounce work.

for a senior

Trace every branch of the validator to prove it emits and completes, choose a failure policy deliberately, and keep the server as the uniqueness authority.

for a principal

Balance feedback latency, backend load and account-enumeration risk when deciding whether live availability checks are worth offering at all.

## Why every keystroke hits the server In Angular's reactive forms, each control has an `updateOn` setting: `'change'` (the default), `'blur'` or `'submit'`. It decides when a user's input is written to the model **and** when validators run. A control without its own setting inherits its parent's, and the default at the top is `'change'`. So with the defaults, every keystroke that passes `Validators.required` and `Validators.minLength(3)` starts a new async validation, which for an availability check means a new HTTP request. Angular does limit the damage in one way: before starting a new validation it **unsubscribes** from the one still in flight. With `HttpClient`, unsubscribing aborts the pending request, and an outdated answer is never written. But the requests are still sent. ## Fix 1: change when the control updates ```ts username = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(3)], asyncValidators: [usernameAvailable(this.http)], updateOn: 'blur', }); ``` With `'blur'`, the value, the validators and `valueChanges` all wait until the field loses focus. One request per edit session. The trade-off is visible: the user gets no feedback while typing, and code that listens to `valueChanges` for this field now fires only on blur. `'submit'` defers everything to form submission, which is usually too late for an availability hint. ## Fix 2: debounce inside the validator A debounce operator on `valueChanges` does nothing for a validator, because the validator is invoked by Angular, not by your stream. Instead, delay inside the function Angular calls: ```ts import {HttpClient} from '@angular/common/http'; import {AbstractControl, AsyncValidatorFn, ValidationErrors} from '@angular/forms'; import {Observable, of, timer} from 'rxjs'; import {catchError, map, switchMap} from 'rxjs/operators'; export function usernameAvailable(http: HttpClient): AsyncValidatorFn { return (control: AbstractControl): Observable<ValidationErrors | null> => timer(400).pipe( switchMap(() => http.get<{available: boolean}>(`/api/usernames/${encodeURIComponent(control.value)}`)), map((res) => (res.available ? null : {usernameTaken: true})), catchError(() => of(null)), ); } ``` Each keystroke calls the function again; Angular unsubscribes from the previous observable, which cancels its `timer` before the request is made. Only the value the user pauses on reaches the server. The control is `PENDING` during the 400 ms as well, which the UI should present as "checking". This trick depends on the validator returning an **Observable**. A `Promise`-based validator cannot be debounced this way: Angular converts the promise to an observable and unsubscribes from it when the value changes, which discards the stale answer, but a promise cannot be cancelled, so its request has already been sent. ## Why it sometimes stays `PENDING` The control leaves `PENDING` only when the validator **emits**. Look for a path that never does: 1. **`catchError(() => EMPTY)`**: `EMPTY` completes without a value, so no result is stored. Use `of(null)` or a specific error such as `{usernameCheckFailed: true}`. 2. **A filter that drops the value** (`filter(v => v.length > 2)`): the stream completes empty. Put that rule in a sync validator instead. 3. **A stream that never completes**, such as `control.valueChanges.pipe(...)`. The docs require a finite observable, and when async validators are passed as an array, even an array of one, Angular combines them with `forkJoin`, which emits only after every one of them completes. 4. **A backend that never answers**: add `timeout()` so a hung request becomes an error you map to a result. ## Deciding what a failure means | Outcome of the call | Return | Effect | |---|---|---| | Name free | `null` | Control `VALID` | | Name taken | `{usernameTaken: true}` | Control `INVALID`, message shown | | Network or server error | `null` or `{usernameCheckFailed: true}` | Allow submit, or block with a retry hint | Returning `null` on failure keeps sign-up working when the check service is down, at the cost of a later server-side rejection. Returning an error blocks sign-up during an outage. Either is defensible; `EMPTY` is not, because it hides the failure behind an endless spinner. ## Checklist for the diagnosis - Confirm the default timing with the network panel: one request per keystroke means `updateOn: 'change'` and no debounce. - Check that sync validators guard the call, since async runs only when they pass. - Trace every branch of the validator's pipe and confirm each one **emits and completes**. - Make the submit button respect `form.pending`, because a pending form is neither valid nor invalid. - Keep the server's uniqueness constraint as the final word; a client check is a hint.

  • Why doesn't adding debounceTime to username.valueChanges reduce the validator's requests?
    Angular calls the async validator itself inside `updateValueAndValidity()`, not through your `valueChanges` subscription. Debouncing a separate stream changes only that stream. To delay the request, delay inside the observable the validator returns, for example with `timer(400).pipe(switchMap(...))`, relying on Angular to unsubscribe the previous one.
  • What changes if you set updateOn: 'submit' on the whole FormGroup instead?
    Every control inherits it unless it sets its own, so values, validators and `valueChanges` all wait until the form is submitted. The availability check then runs once per submit, but the user learns the name is taken only after pressing the button, and, when the sync validators pass, the form is typically still `PENDING` while your submit handler runs.

Debouncing inside the validator is like a receptionist who waits a moment before dialling: if you change your mind and ask again, the first call is torn up before it is ever placed, so only the request you settle on reaches the other office.

saying these in an interview costs you the question

  • Put debounceTime on valueChanges and the validator will send fewer requests.
  • catchError(() => EMPTY) safely treats a failed check as valid.
  • updateOn: 'blur' only delays error messages, not the value.
  • Angular waits for the previous async check to finish before starting another.
  • A passing client availability check means the name is guaranteed free.