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 scheduling — setTimeout, setInterval, requestAnimationFrame, and queueMicrotask, which defer work to a future tick of the event loop. The third is rendering — toLocaleString, 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.
Prerequisites
nodeorjsdom/happy-domforrequestAnimationFrame).TZ=UTCexported in every CI runner so locale-derived dates do not vary by machine.- globals — code that captured a reference to
setTimeoutbefore 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
- Controlling Date.now and setTimeout in Jest — the exact Jest fake-timer lifecycle,
setSystemTime, and async advancement, with the Vitest equivalent shown side by side. - Testing timezone-dependent code with fake timers — pinning
TZ, stabilisingIntl.DateTimeFormatandTemporal, and writing locale-proof date assertions.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- HTTP Request Stubbing Techniques — tear down stubs and clocks together in the same hooks.
- DOM & Browser API Mocking — controlling
requestAnimationFrameand browser-side clocks. - Deterministic seeding for test data in Vitest — pin inputs as well as the clock.
- Flaky-test mitigation — where temporal determinism fits in the wider stability strategy.
Testing Timezone-Dependent Code with Fake Timers
Freeze the clock and pin the timezone so date formatting, Intl, and Temporal assertions pass identically on every machine — a Vitest-first guide with Jest notes.
Controlling Date.now and setTimeout in Jest
Freeze Date.now, advance setTimeout, and drain async timers deterministically in Jest 29+ — with the equivalent Vitest API shown first so you can migrate cleanly.