---
title: "Vitest Fake Timers in Angular: The Mental Model After fakeAsync"
description: "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."
deck: "`fakeAsync` did two jobs at once: it advanced virtual time and it drained Promise microtasks. [Vitest splits them](https://angular.dev/guide/testing/migrating-to-vitest): `vi.useFakeTimers()` controls time, and you `await` the microtasks yourself. Almost every Angular timing test under Vitest is that one idea, applied."
author: "André Ramos"
url: "https://andreramos.dev/angular/vitest-fake-timers-vs-fakeasync-angular/"
lang: "en"
type: "article"
---

# Vitest Fake Timers in Angular: The Mental Model After fakeAsync

> `fakeAsync` did two jobs at once: it advanced virtual time and it drained Promise microtasks. [Vitest splits them](https://angular.dev/guide/testing/migrating-to-vitest): `vi.useFakeTimers()` controls time, and you `await` the microtasks yourself. Almost every Angular timing test under Vitest is that one idea, applied.

## fakeAsync was doing two jobs

`fakeAsync` fused two responsibilities into one helper. `tick(ms)` fast-forwarded timers, and it also flushed pending Promise callbacks. That fusion is why a test waiting on a `.then()` inside a `setTimeout` just worked, and it is why moving to Vitest feels like a step back at first: Vitest does not fuse them.

Vitest gives you the two halves separately. Virtual time is `vi.useFakeTimers()` plus `vi.advanceTimersByTime(ms)`. The microtask half is your own `await`. Hold that split in your head and the rest of the migration is mechanical: wherever `fakeAsync` did both for free, you now do them in two moves.

*The same test, before and after*

```ts
// fakeAsync did both jobs in one call:
it('polls three times', fakeAsync(() => {
  const service = TestBed.inject(PollService);
  service.start();
  tick(3000);
  expect(service.count()).toBe(3);
}));

// Vitest: time is explicit, and there is no Promise here, so no await.
it('polls three times', () => {
  vi.useFakeTimers();
  const service = TestBed.inject(PollService);
  service.start();
  vi.advanceTimersByTime(3000);
  expect(service.count()).toBe(3);
});
```

## The API, mapped

Most of the conversion is a lookup. The one judgment call is whether a Promise is in the chain, which decides between the synchronous advance and the async one.

| What fakeAsync did | Vitest equivalent | When |
|---|---|---|
| `tick(ms)` | `vi.advanceTimersByTime(ms)` | Timers only, nothing returns a Promise |
| `tick(ms)` with a Promise in the chain | `await vi.advanceTimersByTimeAsync(ms)` | Async validator, `.then`, `whenStable` |
| `flush()` | `vi.runAllTimers()` / `await vi.runAllTimersAsync()` | Drain every pending timer at once |
| `discardPeriodicTasks()` | `vi.clearAllTimers()` | Stop intervals before asserting |
| reset between tests | `vi.useRealTimers()` in `afterEach` | Always |

## The single rule that catches everyone

`vi.advanceTimersByTime()` fires timers. It does not drain Promise microtasks. So the moment a Promise enters the chain (an async validator, a `.then`, a `whenStable`), the synchronous advance fires the timer but your assertion runs before the callback resolves. The control is still `PENDING`, the value is still `undefined`, and the failure looks impossible.

The fix is one substitution: `await vi.advanceTimersByTimeAsync(ms)`, which advances time and drains microtasks. The rule of thumb is small enough to memorize: any Promise in the chain, use the `...Async` variant and `await` it. That single rule is most of what makes Vitest timing tests feel different from `fakeAsync`.

*A Promise in the chain needs the async advance*

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

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

  expect(control.status).toBe('INVALID');
});
```

## The parts that are not timers

Three Angular things look like timing but are not, so reaching for `advanceTimersByTime` does nothing useful.

RxJS schedulers do ride the timer. `debounceTime`, `throttleTime`, and the async scheduler all run on the same `setInterval` that `vi` controls, so advancing time fires them with no special handling. That part is easy.

Signal effects do not. An effect flushes during change detection, not on a clock, so advancing time never runs it. Drive it with `TestBed.tick()` (`TestBed.flushEffects()` is deprecated). And change detection itself still has to run: advancing time can update a signal, but an OnPush component only renders after `detectChanges()`.

*An effect is flushed by change detection, not a timer*

```ts
const service = TestBed.inject(ActivityLogService);

TestBed.tick();                 // flush the effect, no timer involved
service.status.set('busy');
TestBed.tick();
expect(service.history()).toEqual(['idle', 'busy']);
```

## When not to reach for fake timers

Fake timers are not always the right tool. For a timing-heavy stream, a marble test models the timeline directly and never touches Zone.js or fake timers: `cold('a-b-c-------|')` through `debounceTime(3)` asserts `'-------c----|'` on `TestScheduler`. For several emissions over time, that is usually clearer than advancing a clock by hand.

And if nothing schedules a timer, you do not need fake timers at all. A plain Promise test just needs to be `async` and to `await` the value. Fake timers earn their place only when there is virtual time to control.

That is the whole model: control time with `vi.useFakeTimers()`, drain microtasks with `await`, flush effects with `TestBed.tick()`, and let marble tests own the timing-heavy streams. The [ProxyZone error](/angular/angular-vitest-fakeasync-proxyzone-error) and its rewrites are this model applied to a broken test; [whether to migrate a suite at all](/angular/move-angular-test-suite-to-vitest) is the decision above it.

## Reusable artifact: Which timer tool for which test

- Only timers, nothing returns a Promise: `vi.advanceTimersByTime(ms)`.
- A Promise anywhere in the chain (async validator, `.then`, `whenStable`): `await vi.advanceTimersByTimeAsync(ms)`.
- A signal effect: `TestBed.tick()`, not a timer.
- An OnPush component: call `detectChanges()` after advancing time so it renders.
- A timing-heavy RxJS stream: a marble test on `TestScheduler`, no fake timers.
- A plain Promise with no timer: make the test `async` and `await`; skip fake timers.
- Always: `vi.useRealTimers()` in `afterEach`.

## FAQ

### What is the difference between vi.advanceTimersByTime and vi.advanceTimersByTimeAsync?

The synchronous `vi.advanceTimersByTime(ms)` fires timers only. The async `vi.advanceTimersByTimeAsync(ms)` also drains Promise microtasks between them. Use the async variant whenever a Promise is in the chain (an async validator, a `.then`, a `whenStable`), or your assertion runs before the result is applied.

### Why did fakeAsync's tick() handle both timers and Promises?

Because `fakeAsync` fused two jobs: advancing virtual time and flushing pending microtasks. `tick(ms)` did both. Vitest separates them, so under the new runner you advance time and drain microtasks in two moves, the second one being an `await`.

### How do I flush a signal effect with Vitest fake timers?

You do not use timers for effects. A signal effect flushes during change detection, not on a clock, so advancing time does nothing for it. Call `TestBed.tick()` instead (`TestBed.flushEffects()` exists but is deprecated).

### Do I need fake timers for an RxJS marble test?

No. `TestScheduler` models the timeline itself and does not depend on Zone.js or fake timers, so marble tests keep working unchanged. For streams with several emissions over time they are often clearer than advancing a clock by hand.

### When should I avoid fake timers entirely?

When nothing schedules a timer. If a test only waits on a Promise with no `setTimeout` or interval underneath, make it `async` and `await` the value. Fake timers are worth it only when there is virtual time to control.

## 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/)
- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
- [The Code That Breaks When Angular Goes Zoneless](https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/)
