---
title: "Why Your Async Validator Stays PENDING Under Vitest Fake Timers"
description: "The silent Vitest timing failure: why an Angular async validator stays PENDING after a fakeAsync migration, why advanceTimersByTime is not enough, and the precise line between a Promise (needs the async advance) and RxJS plus HttpClient (does not)."
deck: "This one does not throw. The test runs, nothing errors, and the assertion fails on a value that should be there: the control is still `PENDING`. It is the quiet cousin of the ProxyZone error, and the fix is a single word. [Vitest fake timers](https://angular.dev/guide/testing/migrating-to-vitest) fire timers but do not drain Promise microtasks, so the validation result never gets applied before you assert."
author: "André Ramos"
url: "https://andreramos.dev/angular/angular-vitest-async-validator-pending/"
lang: "en"
type: "article"
---

# Why Your Async Validator Stays PENDING Under Vitest Fake Timers

> This one does not throw. The test runs, nothing errors, and the assertion fails on a value that should be there: the control is still `PENDING`. It is the quiet cousin of the ProxyZone error, and the fix is a single word. [Vitest fake timers](https://angular.dev/guide/testing/migrating-to-vitest) fire timers but do not drain Promise microtasks, so the validation result never gets applied before you assert.

## The failure that looks impossible

There is no ProxyZone error here. The spec runs, nothing throws, and then it fails on a value that should obviously be set. The async validator was supposed to mark the control invalid, but `control.status` is still `PENDING` and `control.errors` is `null`. Nothing in the output explains why.

This shows up two ways: a `fakeAsync` test you migrated mechanically (swapped `tick` for `vi.advanceTimersByTime` and called it done), or a fresh test that controls time with fake timers. Either way, the timer fired. The result just is not there yet.

*Runs clean, fails anyway*

```ts
it('flags a taken username', () => {
  vi.useFakeTimers();
  const control = new FormControl('taken', {
    asyncValidators: [uniqueUsernameValidator(['taken'])],
  });

  vi.advanceTimersByTime(200);          // fires the timer, not the microtask

  expect(control.status).toBe('INVALID'); // received: 'PENDING'
});
```

## Why: a timer is not a microtask

`vi.advanceTimersByTime()` fires timers, synchronously. A timer is a macrotask: `setTimeout`, `setInterval`, and the RxJS schedulers that ride them. The async validator's debounce is a timer, so the advance does fire it.

But the validator's result is applied when its Promise resolves, and a Promise resolution is a microtask, not a timer. The synchronous advance never drains the microtask queue, so at the moment you assert, the Promise callback that would set the error has not run. The control is still `PENDING`. The failure looks impossible because the validator did run. Its answer is just sitting in a queue you never flushed.

| Mechanism | Kind | Fired by `advanceTimersByTime` (sync)? |
|---|---|---|
| `setTimeout`, `setInterval` | Macrotask (timer) | Yes |
| RxJS `debounceTime`, async scheduler | Macrotask (rides `setInterval`) | Yes |
| `Promise.then`, async validator result, `whenStable` | Microtask | No, needs the async advance |

## The fix is one word: Async

Switch `advanceTimersByTime` for `advanceTimersByTimeAsync`, make the test `async`, and `await` it. The async variant advances time and drains the microtasks in between, so the Promise resolves and the result lands before your assertion. That is the entire fix.

*The async advance drains the microtask*

```ts
it('flags a taken username', async () => {        // now async
  vi.useFakeTimers();
  const control = new FormControl('taken', {
    asyncValidators: [uniqueUsernameValidator(['taken'])],
  });

  await vi.advanceTimersByTimeAsync(200);          // time AND microtasks

  expect(control.status).toBe('INVALID');          // passes
  expect(control.errors).toEqual({ taken: true });
});
```

## It is Promises, not async in general

Here is where people overcorrect and start awaiting everything. They should not. Not every asynchronous-looking test needs the async advance. An RxJS stream that feeds an `HttpClient` call is synchronous inside a test: `vi.advanceTimersByTime` fires the debounce, and `HttpTestingController.flush()` applies the response synchronously. There is no Promise in that chain, so there is no microtask to drain.

The trap is specifically a Promise: an async validator, a `.then`, a `whenStable()`, or a service method that returns a Promise. See one of those, reach for the async advance. When the chain is RxJS and `HttpClient` end to end, the synchronous advance and a `flush()` are correct, and adding `await` just hides what is actually happening.

*RxJS plus HttpClient stays synchronous*

```ts
const httpMock = TestBed.inject(HttpTestingController);

vi.advanceTimersByTime(300);                 // fires the debounce -> http.get
const req = httpMock.expectOne('/api/search?q=ab');
req.flush([{ id: 1, label: 'Abacaxi' }]);    // applied synchronously, no Promise

expect(seen).toEqual([[{ id: 1, label: 'Abacaxi' }]]);
```

## How to recognize it in five seconds

The tell is the shape of the failure: the test runs, nothing throws, and a value is simply missing (`PENDING`, `null`, `undefined`) where it should be set. The moment you suspect timing, ask one question. Is there a Promise in the chain? If yes, change `advanceTimersByTime` to `await advanceTimersByTimeAsync`. If the value appears, that was it.

This is the silent half of the same problem the [ProxyZone error](/angular/angular-vitest-fakeasync-proxyzone-error) announces loudly, and both come from the split that [Vitest fake timers](/angular/vitest-fake-timers-vs-fakeasync-angular) make explicit: time is one job, microtasks are another.

## Reusable artifact: Diagnosing a silent Vitest timing failure

- Symptom: the test runs, nothing throws, but a value is missing (`PENDING`, `null`, `undefined`).
- Check the chain for a Promise: async validator, `.then`, `whenStable`, a Promise-returning service.
- If there is one, make the test `async` and use `await vi.advanceTimersByTimeAsync(ms)`.
- If it is RxJS plus `HttpTestingController` end to end, keep the synchronous advance and `flush()`; there is no microtask to drain.
- Still stuck? Try `await vi.runAllTimersAsync()` to drain every pending timer and its microtasks.
- Reset with `vi.useRealTimers()` in `afterEach`.

## FAQ

### Why does my Angular async validator stay PENDING in a Vitest test?

Because `vi.advanceTimersByTime()` fires the debounce timer but does not drain the Promise microtask that applies the validation result. The control resolves in a microtask that never ran before your assertion, so it stays `PENDING`. Use `await vi.advanceTimersByTimeAsync(ms)`, which advances time and drains microtasks.

### advanceTimersByTime is not working, the value is still undefined. Why?

It is working, but only on timers. Anything applied through a Promise (an async validator, a `.then`, `whenStable`) lands in a microtask, and the synchronous advance does not drain microtasks. Switch to the async variant and `await` it.

### What is the difference between a timer and a microtask here?

A timer (`setTimeout`, `setInterval`, and the RxJS schedulers on top of them) is a macrotask, and `advanceTimersByTime` fires it. A Promise resolution is a microtask, drained only by the async advance (`advanceTimersByTimeAsync`, `runAllTimersAsync`) or a real `await`.

### Do my HttpClient tests need advanceTimersByTimeAsync too?

Usually not. An RxJS stream feeding `HttpClient` is synchronous in a test: advance the timer for the debounce, then `HttpTestingController.flush()` applies the response synchronously. You only need the async advance if a Promise sits in the chain, for example a `.then` after the response.

### Does runAllTimers fix a PENDING validator?

No, use the async version: `await vi.runAllTimersAsync()`. The async timer helpers drain microtasks between timers, which is what lets the validator's Promise resolve. The synchronous `runAllTimers` has the same blind spot as `advanceTimersByTime`.

## Sources checked

- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/core/testing/TestBed
- https://vitest.dev/api/vi
- https://vitest.dev/guide/mocking
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b

## Related

- [fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest](https://andreramos.dev/angular/angular-vitest-fakeasync-proxyzone-error/)
- [Vitest Fake Timers in Angular: The Mental Model After fakeAsync](https://andreramos.dev/angular/vitest-fake-timers-vs-fakeasync-angular/)
- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
