Time & Date Control Strategies

Time is the most under-acknowledged source of non-determinism in a JavaScript test suite. The moment a unit of code reads Date.now(), schedules a setTimeout, polls on an interval, or formats a date for a user’s locale, its behaviour becomes a function of when and where the test runs rather than what the code does. That is precisely what these techniques eliminate, and they sit inside the broader discipline of Advanced Mocking & Service Isolation Patterns: just as you replace the network with a controlled stand-in, you replace the system clock with a clock you fully own. This guide is the reference for installing a fake clock, freezing the wall time, advancing timers in lockstep with the microtask queue, and keeping every temporal assertion stable across machines, CI runners, and timezones.

Architectural Scope & Boundaries

These techniques apply at the unit and integration tiers — anywhere your test process owns the JavaScript runtime and can patch its global timing APIs (Date, setTimeout, setInterval, clearTimeout, clearInterval, queueMicrotask, requestAnimationFrame, performance.now). In that boundary, Vitest’s vi.useFakeTimers() and Jest’s jest.useFakeTimers() both wrap Sinon’s @sinonjs/fake-timers, so the mental model is identical across runners.

What this does not cover: real browser navigation timing under a full end-to-end run. When the clock you need to control lives inside a separate browser context driven by Playwright, you reach for page.clock instead of a runner-level fake timer — a distinct API documented alongside DOM & browser API mocking. Fake timers also do not, by themselves, make data deterministic: if your fixtures embed new Date() or random IDs at generation time, pair clock control with deterministic seeding for test data so both the clock and the inputs are pinned.

The boundary rule is simple: fake the clock as close to the unit under test as you can, freeze it to a fixed epoch, and restore real timers in teardown so the fake clock never leaks into a neighbouring file.

A useful way to reason about the scope is to separate the three temporal inputs a unit can read. The first is the current instant — every Date.now() and new Date() with no argument. The second is schedulingsetTimeout, setInterval, requestAnimationFrame, and queueMicrotask, which defer work to a future tick of the event loop. The third is renderingtoLocaleString, Intl.DateTimeFormat, and Temporal, which turn an instant into human-readable text using the host timezone and locale. Fake timers own the first two completely; the third needs an additional environment pin (covered in the timezone guide below). Drawing that line up front stops the common mistake of freezing the clock and then being surprised that a formatted-date assertion still varies by machine.

Fake timer advancement timeline A frozen clock at a fixed epoch, then three discrete jumps as advanceTimersByTime fires scheduled callbacks at 1000, 2000, and 3000 milliseconds while real wall time stays still. vi.useFakeTimers() — wall time frozen, virtual clock advances on command t=0 frozen virtual time (ms) → +1000 cb #1 +2000 cb #2 +3000 cb #3 advanceTimersByTime(1000) → fires the next due callback

Prerequisites

  • node or jsdom/happy-dom for requestAnimationFrame).
  • TZ=UTC exported in every CI runner so locale-derived dates do not vary by machine.
  • globals — code that captured a reference to setTimeout before the fake clock installed will keep using the real one.
  • afterEach) already wired in your setup file, mirroring the isolation discipline used across HTTP request stubbing techniques so stubs and clocks are torn down together.

Step-by-Step Implementation

Step 1 — Install and freeze the clock at a fixed epoch

Anchor the virtual clock to a known instant so every Date read returns the same value. Freeze before the unit under test imports, not after.

import { beforeEach, afterEach, vi } from 'vitest';

const FIXED = new Date('2026-01-01T00:00:00.000Z');

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(FIXED);
});

afterEach(() => {
  vi.useRealTimers();
});

Step 2 — Choose exactly which globals to fake

Faking only what you need keeps unrelated async behaviour real. Pass toFake to scope the patch.

vi.useFakeTimers({
  toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
  now: new Date('2026-01-01T00:00:00.000Z'),
});

Step 3 — Advance synchronous timers deterministically

Drive the virtual clock forward to the exact boundary you want to assert at. No real waiting occurs.

it('debounces to a single call', () => {
  const fn = vi.fn();
  const debounced = debounce(fn, 300);
  debounced(); debounced(); debounced();

  vi.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledTimes(1);
});

Step 4 — Drain promises with the async timer API

When a callback awaits a promise, advancing time alone is not enough — the microtask queue must flush too. Use the *Async variants so the runner interleaves macrotasks and microtasks correctly.

it('retries after a backoff delay', async () => {
  const result = retryWithBackoff(fetchOnce, { delayMs: 1000 });
  await vi.advanceTimersByTimeAsync(1000); // fires the timer AND flushes awaited promises
  await expect(result).resolves.toEqual({ ok: true });
});

Step 5 — Run intervals to a hard ceiling

Recurring timers can loop forever under runAllTimers(). Bound the advancement explicitly.

it('polls three times then stops', () => {
  const tick = vi.fn();
  const id = setInterval(tick, 1000);
  setTimeout(() => clearInterval(id), 3500);

  vi.advanceTimersByTime(3500);
  expect(tick).toHaveBeenCalledTimes(3); // 1000, 2000, 3000
});

Step 6 — Restore real timers and assert nothing leaked

Teardown is part of the implementation, not an afterthought. Restoring real timers in afterEach and, where helpful, asserting that the timer queue is empty turns a silent leak into a loud, local failure.

afterEach(() => {
  expect(vi.getTimerCount()).toBe(0); // optional guard: no orphaned timers
  vi.useRealTimers();
});

The Jest equivalent is a one-to-one swap — jest.useFakeTimers(), jest.setSystemTime(), jest.advanceTimersByTimeAsync(), jest.useRealTimers() — because both runners delegate to the same @sinonjs/fake-timers engine. The only differences are the namespace (vi. versus jest.) and the import path (vitest versus @jest/globals). The full Jest lifecycle, including setupFilesAfterEnv wiring and the fakeTimers.enableGlobally config flag, is covered in depth in controlling Date.now and setTimeout in Jest.

Configuration Reference Table

Options accepted by vi.useFakeTimers(options) (Vitest forwards them to @sinonjs/fake-timers; Jest exposes the same set under fakeTimers in config):

Option Type Default Effect
now number | Date current time Initial epoch the virtual clock starts at.
toFake string[] all supported APIs Whitelist of globals to patch (Date, setTimeout, setInterval, queueMicrotask, requestAnimationFrame, performance, …).
loopLimit number 10000 Max callbacks runAllTimers() will fire before throwing, guarding against infinite recursion.
shouldAdvanceTime boolean false If true, the virtual clock also creeps forward with real time between manual advances.
advanceTimeDelta number 20 Step size (ms) used when shouldAdvanceTime is enabled.
shouldClearNativeTimers boolean false Routes clearTimeout/clearInterval to also clear any real timers, easing migration.
toReal (Jest enableGlobally) boolean Jest-only config flag to install fake timers for the whole suite automatically.

Verification & Assertions

Confirm the clock is actually frozen before you trust any downstream assertion. A direct read is the cheapest sanity check:

expect(Date.now()).toBe(new Date('2026-01-01T00:00:00.000Z').getTime()); // 1767225600000

Assert pending-timer state with the runner’s introspection helpers, then advance and re-check:

setTimeout(() => {}, 500);
expect(vi.getTimerCount()).toBe(1);
vi.runOnlyPendingTimers();
expect(vi.getTimerCount()).toBe(0);

For async flows, assert on the resolved value after draining rather than on a spy alone — a passing toHaveBeenCalled with an unflushed promise is a false positive. Always finish with a teardown assertion mindset: if a test leaves fake timers installed, the next file inherits a frozen clock and fails mysteriously, so vi.useRealTimers() in afterEach is non-negotiable.

Edge Cases & Failure Modes

Captured timer references. If a module does const t = setTimeout at import time before useFakeTimers() runs, it holds the real function. Fix: install fake timers in a setup file that loads before the module, or fake timers before the dynamic import().

Microtask starvation. advanceTimersByTime() fires macrotasks but does not await promises chained inside them, so await-ed assertions hang or read stale state. Fix: use advanceTimersByTimeAsync() / runAllTimersAsync(), which yield to the microtask queue between firings.

Infinite interval loops. runAllTimers() on a setInterval that reschedules itself throws once it hits loopLimit. Fix: prefer advanceTimersByTime() with an explicit ceiling, or runOnlyPendingTimers() to fire just the currently queued callbacks.

requestAnimationFrame not faked. rAF only exists in a DOM environment and is not in the default toFake list under node. Fix: run the test in jsdom/happy-dom and add 'requestAnimationFrame' to toFake, then advance with vi.advanceTimersToNextFrame().

Date arithmetic that crosses a DST boundary. A unit that adds “24 hours” with new Date(t + 86_400_000) lands on the wrong wall-clock time when the test instant straddles a daylight-saving transition, because a civil day is not always 86.4 million milliseconds. Fix: assert on the UTC instant rather than the local representation, or move calendar arithmetic to a zone-aware API such as Temporal.ZonedDateTime, then pin the zone as described in testing timezone-dependent code with fake timers.

Mixing real and fake timers in one test. Code under test that resolves a real promise (a genuine I/O call) while the clock is faked will never settle, because the macrotask that would resume it is queued in the virtual registry and never fired. Fix: keep genuinely asynchronous I/O behind a stub so the only timers in play are the virtual ones, the same separation enforced by HTTP request stubbing techniques.

Performance & CI Impact

Fake timers are a net speedup: a test that would otherwise sleep for 30 seconds resolves instantly because no wall time elapses, which collapses backoff, polling, and timeout suites from minutes to milliseconds. The per-tick overhead of advanceTimersByTime() is well under a millisecond, so even tens of thousands of advances are cheap.

The CI risk is leakage, not speed. A file that forgets useRealTimers() poisons subsequent files in the same worker, producing failures that vanish when the file is run in isolation — the classic signature of order-dependent flakiness. Enforce restoration globally in your setup file and standardise TZ=UTC on every runner so the same epoch formats identically everywhere. These habits pair naturally with the quarantine-and-stabilise workflow described under flaky-test mitigation, keeping temporal tests both fast and reproducible across parallel pools.

In-Depth Guides