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 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 anywayts
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.

MechanismKindFired by advanceTimersByTime (sync)?
setTimeout, setIntervalMacrotask (timer)Yes
RxJS debounceTime, async schedulerMacrotask (rides setInterval)Yes
Promise.then, async validator result, whenStableMicrotaskNo, 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 microtaskts
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 synchronousts
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 announces loudly, and both come from the split that Vitest fake timers 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

Angular Vitest Migration Kit

Migrating this suite to Vitest?

The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.

Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.

Get the kit · US$15 →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 9 min

fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

Why fakeAsync, tick, and waitForAsync throw the ProxyZone error under Angular's Vitest runner, and the two fixes: the experimental zone testing patch, or a rewrite to Vitest fake timers.

Read article →

Guide · 7 min

Vitest Fake Timers in Angular: The Mental Model After fakeAsync

How to think about Vitest fake timers in Angular after fakeAsync: the two jobs fakeAsync fused, the API map, the Angular-specific parts (effects, change detection, RxJS), and when not to use fake timers at all.

Read article →

Guide · 6 min

When to Move an Angular Test Suite to Vitest

Vitest is the Angular CLI default, but migrating an existing suite is still experimental: which specs to pilot first, what fakeAsync breaks, and how to keep CI trustworthy.

Read article →