Controlling Date.now and setTimeout in Jest

Time-dependent code — token expiry, debounce, retry backoff, polling intervals — fails intermittently the moment a test reads the real system clock. This guide shows frontend, full-stack, and platform engineers how to freeze Date.now() and advance setTimeout/setInterval deterministically. Vitest is the primary runner here (its API mirrors Jest one-to-one because both wrap @sinonjs/fake-timers), with the Jest 29+ equivalent given for every pattern so an existing Jest suite needs no conceptual translation. It is one technique inside the broader time & date control strategies that keep a suite reproducible.

Root Cause Analysis

Native setTimeout schedules its callback on the host event loop, which runs after the synchronous body of your test has finished and its assertions have already evaluated. So a naive setTimeout(cb, 1000); expect(cb).toHaveBeenCalled() checks the spy before the callback could ever fire — a guaranteed false negative. Worse, Date.now() returns live wall-clock time, so any snapshot or equality assertion against a timestamp drifts by milliseconds between runs and breaks under parallel execution.

The fix is to replace those globals with a virtual clock the test fully owns. A fake clock freezes Date at a fixed epoch and queues setTimeout/setInterval callbacks in a controllable registry that only fires when you advance it. This is exactly the isolation principle from Advanced Mocking & Service Isolation Patterns: swap an uncontrolled dependency — here, the clock — for a deterministic stand-in. The most common failure mode after adopting fake timers is forgetting that captured references to the real setTimeout (grabbed at import time before the fake installs) keep using the host scheduler.

There is a second, subtler vector worth naming up front: the boundary between macrotasks and microtasks. setTimeout schedules a macrotask, but most application code awaits promises, which resolve as microtasks. A fake clock controls the macrotask queue, yet the engine still drains microtasks on its own schedule between macrotasks. When a timer callback itself returns a promise — a retry that awaits a fetch, a debounce that resolves a deferred — advancing the clock fires the macrotask but leaves the chained microtask pending unless you yield. That is why the synchronous and asynchronous advancement APIs exist as a pair, and choosing the wrong one is the difference between a test that asserts the real resolved value and one that asserts against stale, half-settled state.

A third vector trips up teams that adopt fake timers incrementally: partial faking of the timer set. Both runners let you fake only a subset of timer functions while leaving others real. Jest’s jest.useFakeTimers() historically faked setTimeout, setInterval, Date, and the queue-microtask family, but in some configurations leaves process.nextTick, queueMicrotask, or performance.now untouched. If the code under test debounces with requestAnimationFrame and you only faked setTimeout, the debounce never fires under test and you chase a phantom bug. Decide explicitly which APIs to virtualize. In Vitest you pass vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] }); in Jest you pass jest.useFakeTimers({ doNotFake: ['nextTick'] }). Naming the set turns an implicit assumption into a reviewable line of config.

Finally, beware the real-Date capture in fixtures. A test data factory that calls new Date() at module evaluation time — before any beforeEach runs — records the real wall clock and bakes it into every fixture for the whole file. The frozen system time you set later never touches those values, so snapshot assertions drift. The fix is the same isolation discipline that governs all Advanced Mocking & Service Isolation Patterns: compute time-derived fixtures lazily inside the test, after the clock is installed, never at import time.

Reproducible Setup

Install the runner and create a setup file that installs and restores the fake clock around every test.

npm install -D vitest        # primary
npm install -D jest @jest/globals ts-jest   # secondary, if on Jest

Vitest setup (primary):

// vitest.setup.ts
import { beforeEach, afterEach, vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});

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

Jest equivalent:

// jest.setup.ts
import { jest } from '@jest/globals';

beforeEach(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});

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

Register the file in config (setupFiles: ['./vitest.setup.ts'] for Vitest, setupFilesAfterEnv: ['./jest.setup.ts'] for Jest) and export TZ=UTC in CI so the frozen epoch formats identically everywhere.

Implementation

1. Freeze Date.now() to a fixed epoch. Use setSystemTime rather than reassigning Date.now directly — it patches the global Date constructor safely and is undone by useRealTimers().

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

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

2. Advance a synchronous setTimeout. Drive the virtual clock to the exact boundary, then assert.

it('fires after 1000 ms', () => {
  const cb = vi.fn();          // jest.fn() in Jest
  setTimeout(cb, 1000);

  vi.advanceTimersByTime(999); // jest.advanceTimersByTime(999)
  expect(cb).not.toHaveBeenCalled();

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

3. Drain a setTimeout whose callback awaits a promise. Advancing alone will not flush the microtask queue — use the async variant.

it('resolves after a backoff delay', async () => {
  const work = retryWithBackoff(fetchOnce, { delayMs: 1000 });

  await vi.advanceTimersByTimeAsync(1000); // jest.advanceTimersByTimeAsync(1000)

  await expect(work).resolves.toEqual({ ok: true });
});

4. Bound a setInterval. Advance to a ceiling instead of runAllTimers(), which loops forever on self-rescheduling intervals.

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);
});

5. Spy on Date.now for a single read without a full clock. When you only need one timestamp pinned and no timers, a scoped spy is lighter.

const spy = vi.spyOn(Date, 'now').mockReturnValue(1767225600000); // jest.spyOn
expect(Date.now()).toBe(1767225600000);
spy.mockRestore();

6. Fake timers before a module captures them. When the unit under test grabs a timer reference at import time, install the fake clock first, then import dynamically so the module sees the patched globals.

it('intercepts a timer captured at module load', async () => {
  vi.useFakeTimers();                       // patch BEFORE the import
  const { startHeartbeat } = await import('./heartbeat');
  const beat = vi.fn();
  startHeartbeat(beat, 5000);

  vi.advanceTimersByTime(15000);
  expect(beat).toHaveBeenCalledTimes(3);
});

This ordering is the single most common fix for “my callback never fires even though I mocked timers”: the module had already closed over the real setTimeout by the time the fake installed.

7. Test a debounce end to end. Debounce is the canonical timer pattern: rapid calls should collapse into one trailing invocation. A fake clock lets you assert the collapse precisely instead of sleeping for the debounce window.

function debounce<A extends unknown[]>(fn: (...a: A) => void, ms: number) {
  let id: ReturnType<typeof setTimeout> | undefined;
  return (...args: A) => {
    if (id) clearTimeout(id);
    id = setTimeout(() => fn(...args), ms);
  };
}

it('collapses rapid calls into one trailing invocation', () => {
  const spy = vi.fn();                 // jest.fn()
  const debounced = debounce(spy, 200);

  debounced('a');
  vi.advanceTimersByTime(100);         // jest.advanceTimersByTime
  debounced('b');                      // resets the window
  vi.advanceTimersByTime(199);
  expect(spy).not.toHaveBeenCalled();  // window not yet elapsed

  vi.advanceTimersByTime(1);
  expect(spy).toHaveBeenCalledExactlyOnceWith('b');
});

8. Advance through a chained retry with backoff. When each retry both waits and awaits a network call, fire the macrotask and flush the microtask in one async step. This is the timer side of the teardown discipline shared with HTTP request stubbing techniques, where the request mock and the clock are torn down in the same hooks.

it('retries twice then succeeds with exponential backoff', async () => {
  const attempt = vi
    .fn()
    .mockRejectedValueOnce(new Error('503'))
    .mockRejectedValueOnce(new Error('503'))
    .mockResolvedValueOnce({ ok: true });

  const work = retryWithBackoff(attempt, { base: 100, factor: 2 });

  await vi.advanceTimersByTimeAsync(100);  // first backoff
  await vi.advanceTimersByTimeAsync(200);  // second backoff (100 * 2)

  await expect(work).resolves.toEqual({ ok: true });
  expect(attempt).toHaveBeenCalledTimes(3);
});

If you advance the wrong amount — say 150 ms when the backoff is 200 ms — the awaited promise never settles and the test hangs until the runner’s own timeout fires. The fix is always to mirror the production delay schedule exactly, which is why deriving the schedule from a single base/factor pair (rather than scattered literals) keeps the test honest.

9. Combine a frozen clock with timer advancement. Freezing Date and advancing timers are independent: setSystemTime does not move the clock when you advance timers unless you opt in. Vitest advances the system time alongside advanceTimersByTime, so a Date.now() read inside a fired callback reflects the elapsed virtual time — exactly what code that timestamps each retry expects.

it('stamps each tick with advancing virtual time', () => {
  vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
  const stamps: number[] = [];
  setInterval(() => stamps.push(Date.now()), 1000);

  vi.advanceTimersByTime(3000);
  expect(stamps).toEqual([
    1767225601000, 1767225602000, 1767225603000,
  ]);
});

Verification

A passing run reads the frozen epoch and fires every queued callback exactly when advanced:

✓ fires after 1000 ms
✓ resolves after a backoff delay
✓ polls three times then stops

Test Files  1 passed (1)
     Tests  3 passed (3)

Cross-check timer state explicitly so a green spy assertion is never a false positive from an unflushed queue:

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

If Date.now() returns a value other than your fixed epoch, the clock was never installed (the import order captured real timers) — see Troubleshooting below.

Troubleshooting

Callback never fires / await hangs. You advanced with the synchronous API while the callback awaits a promise. Switch to advanceTimersByTimeAsync() (Jest 29.1+) or runAllTimersAsync() so the runner flushes microtasks between firings.

Date.now() still returns wall time. A module captured Date/setTimeout at import time before the fake installed. Move useFakeTimers() into a setup file that loads first, or call it before a dynamic import() of the module under test.

Clock leaks into the next file. A test left fake timers installed because useRealTimers() was missing in teardown, so the following file inherits a frozen clock and fails only when run after it. Always restore in afterEach; this is the same order-dependent flakiness handled in flaky-test mitigation.

runAllTimers() hangs forever. A setInterval or a self-rescheduling setTimeout keeps queuing new work, so the “run all” loop never empties the queue. Replace it with advanceTimersByTime(ceiling) bounded to the duration you care about, or runOnlyPendingTimers() which fires the currently queued callbacks without draining ones they schedule.

expect(spy).toHaveBeenCalled() passes but the value is wrong. The spy fired, but the promise inside its callback had not resolved when you asserted, so downstream state is stale. This is the macrotask/microtask split: switch the synchronous advance to advanceTimersByTimeAsync and await it so the chained .then settles before the assertion.

Timezone-sensitive assertions differ between your laptop and CI. setSystemTime fixes the instant, but toLocaleString and Intl.DateTimeFormat still read the host timezone. Export TZ=UTC in the runner environment, or pass an explicit timeZone option to the formatter. The deeper treatment lives in testing timezone-dependent code with fake timers.

performance.now() does not advance. By default the fake-timer set may not include performance, so animation or profiling code that measures elapsed time reads zero. Add it to the faked set (toFake: ['setTimeout', 'performance'] in Vitest) and it will track the virtual clock.

FAQ

Does this work the same in Jest and Vitest?

Yes — both runners delegate to @sinonjs/fake-timers, so useFakeTimers, setSystemTime, advanceTimersByTime, and advanceTimersByTimeAsync behave identically; only the namespace differs (vi. vs jest.) and the import path (vitest vs @jest/globals). Migrating a suite is a find-and-replace of the prefix.

Why does my async callback never resolve under fake timers?

Because advanceTimersByTime() fires macrotasks but does not yield to the microtask queue, so any promise awaited inside the callback stays pending. Use the async variants (advanceTimersByTimeAsync / runAllTimersAsync) and await them, which interleave timer firing with promise resolution.

Should I mock Date.now directly with a spy or use fake timers?

Use a scoped spyOn(Date, 'now') when you only need a single pinned timestamp and no timers are involved, since it is cheaper and easier to reason about. Reach for full fake timers whenever setTimeout, setInterval, or Date construction interact, because the spy only patches one method and leaves scheduling real.

How do I keep timestamps stable across CI machines?

Freeze the clock with setSystemTime to an explicit UTC instant and export TZ=UTC in the runner environment, so both the epoch and any locale-derived formatting are identical everywhere. For assertions that format dates, see testing timezone-dependent code with fake timers.