skip to content

In an Angular fakeAsync test, why can flush() leave an RxJS debounceTime unfired, and what do discardPeriodicTasks and the flush option change?

level: seniorimportance: should knowfreq 30%

answer

  1. how the async scheduler keeps time
  2. flush skips a kind of timer
  3. end-of-test flushing since 0.15
  4. throwing only when you opt out

basics

~20 s

RxJS's async scheduler, used by debounceTime, schedules with setInterval, and flush() stops once only periodic timers remain, so the debounce may never fire. Use tick(ms). discardPeriodicTasks drops leftover intervals; fakeAsync's default flush option flushes timers at the end.

solid answer

~40 s

`flush()` in `fakeAsync` drains **non-periodic** timers: it stops as soon as only periodic ones (`setInterval`, animation frames) are left. RxJS's `asyncScheduler` — the default for `debounceTime`, `delay` and `timer` — schedules actions with `setInterval`, so a pending debounce can survive a `flush()` and the assertion after it fails. Advance with `tick(300)` instead. At the end of the body, `fakeAsync` flushes pending timers by default (zone.js 0.15 and later), periodic ones included. With `fakeAsync(fn, {flush: false})` it instead throws 'N periodic timer(s) still in the queue' or 'N timer(s) still in the queue'; `discardPeriodicTasks()` removes leftover intervals, such as a polling `interval()`, before the body ends. A timer that reschedules itself forever makes `flush()` throw after its turn limit (20 by default).

go deeper

for a junior

Remember to advance RxJS timers with tick(ms) inside fakeAsync, and that flush() may not fire them.

for a middle

Explain periodic versus non-periodic timers, why RxJS's scheduler counts as periodic, and what the flush option does at the end of the body.

for a senior

Diagnose flaky or silently passing fakeAsync tests from timer kinds, use strict mode to catch leftover work, and fix leaks in components rather than discarding them.

for a principal

Decide whether strict fakeAsync is a suite convention, and how much zone-specific timer knowledge the team should keep investing in before a zoneless move.

## The symptom A zone-based test types into a debounced search, calls `flush()` to "run everything", and asserts that the API was called. It was not. Adding `tick(300)` instead makes the test pass. Nothing in the component changed. ## Periodic and non-periodic timers Inside `fakeAsync`, zone.js keeps a queue of scheduled tasks on a virtual clock, and it distinguishes two kinds: - **Non-periodic:** `setTimeout`, and promise-based work queued through timers. - **Periodic:** `setInterval`, plus `requestAnimationFrame`, which zone.js's `flush()` treats the same way. `flush(maxTurns?)` repeatedly advances to the next **non-periodic** timer. When the only tasks left are periodic, it stops and returns the virtual time it advanced. That is deliberate: an interval never "finishes", so flushing it would loop forever. ## Why RxJS timers are periodic here RxJS 7's `asyncScheduler` — the default scheduler for `debounceTime`, `delay`, `timer`, `auditTime`, `throttleTime` and friends — schedules each action with **`setInterval`**, not `setTimeout`. Its source explains why: reusing one interval for repeated actions is more reliable than chaining timeouts, and the interval is cleared once the action is done. So a pending `debounceTime(300)` looks like a periodic timer to zone.js, and `flush()` can stop without firing it. | Call | Fires a pending `debounceTime`? | Why | |---|---|---| | `tick(300)` | Yes | Advances time; runs every task that falls due, periodic or not | | `flush()` | Not reliably | Stops when only periodic tasks remain | | `flushMicrotasks()` | No | Runs promise callbacks only; does not advance time | | End of a default `fakeAsync` body | Yes | The final flush ticks to the last queued task, periodic included | **Rule of thumb:** in RxJS-heavy code, advance time with `tick(ms)`. ## What happens when the body ends Zone.js 0.15 changed `fakeAsync` so that it **flushes pending timers by default** when the body returns. With the default `{flush: true}`, a leftover debounce or delay simply runs, and the test ends quietly. With `fakeAsync(fn, {flush: false})`, the older, stricter behaviour applies: after draining microtasks, the test throws if work is left: - "1 periodic timer(s) still in the queue." — an interval, including one from RxJS or a polling `interval(5000)`. - "2 timer(s) still in the queue." — pending timeouts. Strict mode is useful: it proves the test accounted for every timer the component started. ## discardPeriodicTasks `discardPeriodicTasks()` removes every pending periodic task from the fake queue without running it. Use it at the end of a strict-mode test for a component that polls on purpose: ```ts it('polls stock every 5 s', fakeAsync(() => { const fixture = TestBed.createComponent(StockBadge); fixture.detectChanges(); tick(5000); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('In stock'); discardPeriodicTasks(); // the component keeps polling; this test is done }, {flush: false})); ``` Discarding is a statement about the test, not a fix for a leak. If the interval should have stopped — the component was destroyed, the stream completed — the right fix is in the component, for example ending the subscription with `takeUntilDestroyed()`. ## What tick does differently `tick(ms)` advances the virtual clock by exactly `ms` and runs **every** task that falls due in that window, periodic or not. With the default options it also runs new timers that callbacks schedule inside the window, and it drains microtasks before and after each callback. That is why it is the reliable way to drive RxJS time operators: the debounce's interval falls due at 300 ms and runs like any other task. It is also why `tick` is safe with components that poll — it stops at the time you asked for instead of chasing an interval forever. ## The turn limit `flush()` gives up after **20** turns by default and throws: "flush failed after reaching the limit of 20 tasks. Does your code use a polling timeout?" That happens when code keeps scheduling a new `setTimeout` from inside the previous one — a hand-written polling loop. Options: 1. Advance with `tick(ms)` for the period you care about. 2. Pass a higher `maxTurns` if the chain is genuinely finite. 3. Make the loop stoppable and stop it in the test. ## Diagnosis checklist 1. Does the code under test use RxJS time operators? Advance with `tick`, not `flush`. 2. Is the test in strict mode (`{flush: false}`)? Account for or discard intervals. 3. Does `flush()` hit the turn limit? Look for self-rescheduling timeouts. 4. Is the suite moving to zoneless on Vitest? None of this applies there; the runner's fake timers have their own rules.

  • Why does zone.js's flush() stop at periodic timers instead of running them?
    A periodic timer never completes on its own; each run schedules the next. If `flush()` drained periodic timers until the queue was empty, any `setInterval` would loop forever. So it runs non-periodic timers until only periodic ones remain, and leaves time control for intervals to `tick(ms)`.
  • Is calling discardPeriodicTasks() a good fix for a component whose interval keeps running after it is destroyed?
    No. It only hides the interval from the fake clock in that test. A component that polls after destruction leaks in production too; stop the stream when the component is destroyed, for example with `takeUntilDestroyed()`, and let the test prove it by destroying the fixture and checking nothing is pending.

saying these in an interview costs you the question

  • flush() runs every pending timer, so it always fires a debounce.
  • RxJS debounceTime schedules its work with setTimeout.
  • A fakeAsync test always throws when periodic timers are left over.
  • discardPeriodicTasks() is the fix for an interval that should have stopped.
  • flushMicrotasks() advances the virtual clock to the next timer.