Skip to main content
Back to Blog
Guide
2026-08-08

Jest Fake Timers Modern vs Legacy: Migration and Debugging Guide

Compare Jest fake timers modern vs legacy, migrate safely, handle promises and system time, and diagnose hangs with runnable, deterministic test patterns.

Jest Fake Timers Modern vs Legacy: Migration and Debugging Guide

The practical answer to Jest fake timers modern vs legacy is simple: use modern fake timers for new tests, and keep legacy mode only as a temporary compatibility bridge for suites whose assumptions you have not yet migrated. Calling jest.useFakeTimers() selects the current implementation. Calling jest.useFakeTimers({ legacyFakeTimers: true }) explicitly selects the old Jest mock-function implementation.

Modern timers are backed by @sinonjs/fake-timers and can control a broader clock surface, including Date and APIs needed for virtual system time. They also provide asynchronous timer-advancement methods for callbacks that schedule promises. Legacy timers replace a narrower group of scheduling functions with Jest mocks. The difference is behavioral, not merely a rename, so a global search-and-replace is not a sufficient migration plan.

This guide builds a migration harness around observable behavior. It complements a wider JavaScript testing frameworks guide and, for browser tests where clock control meets element synchronization, the Playwright locator practices guide. The examples use documented Jest APIs and make each clock decision visible in the test.

Start with the actual timer surfaces

Fake timers can affect more than setTimeout. The exact faked APIs depend on the environment and configuration. Current Jest documentation at https://jestjs.io/docs/jest-object and https://jestjs.io/docs/timer-mocks should be the reference for the Jest version pinned by your lockfile.

CapabilityModern defaultLegacy optionMigration impact
Enablementjest.useFakeTimers()jest.useFakeTimers({ legacyFakeTimers: true })Make legacy selection explicit
ImplementationBacked by @sinonjs/fake-timersJest mock functionsMock-call assumptions may differ
Virtual DateSupportedNot the same clock modelReplace manual Date spies where suitable
Set current clockjest.setSystemTime(...)Not availableTime-dependent tests need a modern path
Async advancementadvanceTimersByTimeAsync and related async APIsNot availablePromise-producing callbacks need different flushing
Selective fakingdoNotFake optionLegacy options are not supportedAudit tests depending on real performance or Date
Timer countjest.getTimerCount()Documented for fake timersUseful leak diagnostic in both modes

Do not infer that every asynchronous operation becomes virtual. A fake clock does not turn a real HTTP response, database query, filesystem operation, or arbitrary promise into a timer. It controls the documented clock and scheduling APIs that were replaced. Tests still need separate fakes or controlled infrastructure for external work.

Use an explicit lifecycle in every file that enables fake timers:

// test/timer-lifecycle.test.ts
afterEach(() => {
  jest.clearAllTimers();
  jest.useRealTimers();
  jest.restoreAllMocks();
});

test('uses a virtual timeout without leaking clock state', () => {
  jest.useFakeTimers();
  const callback = jest.fn();

  setTimeout(callback, 250);
  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(250);
  expect(callback).toHaveBeenCalledTimes(1);
});

Restoring real timers prevents one test's virtual clock from changing a later test. Clearing timers is particularly helpful when a failing assertion stops a test before a recurring interval is consumed. Restoring spies is separate because useRealTimers is not a universal mock cleanup command.

Compare behavior with one production example

Consider a polling helper that invokes an asynchronous probe until it returns true or attempts are exhausted:

// src/wait-for-ready.ts
export interface WaitOptions {
  intervalMs: number;
  maxAttempts: number;
}

export async function waitForReady(
  probe: () => Promise<boolean>,
  options: WaitOptions,
): Promise<boolean> {
  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
    if (await probe()) {
      return true;
    }

    if (attempt < options.maxAttempts) {
      await new Promise<void>((resolve) => {
        setTimeout(resolve, options.intervalMs);
      });
    }
  }

  return false;
}

The timer does not exist until the first awaited probe resolves. That ordering matters. Advancing five seconds immediately after calling waitForReady can happen before any timeout is scheduled. A robust modern-timer test first lets the function reach the timer, then advances it with an asynchronous timer API:

// test/wait-for-ready.test.ts
import { waitForReady } from '../src/wait-for-ready';

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

test('returns true on the third asynchronous probe', async () => {
  jest.useFakeTimers();
  const probe = jest.fn()
    .mockResolvedValueOnce(false)
    .mockResolvedValueOnce(false)
    .mockResolvedValueOnce(true);

  const resultPromise = waitForReady(probe, {
    intervalMs: 1_000,
    maxAttempts: 3,
  });

  await jest.advanceTimersByTimeAsync(2_000);

  await expect(resultPromise).resolves.toBe(true);
  expect(probe).toHaveBeenCalledTimes(3);
});

The asynchronous advancement allows promise callbacks scheduled during timer execution to run before control returns. The test asserts the public result and the meaningful interaction count. It does not assert the implementation's entire scheduling sequence.

What people get wrong is repeating jest.runAllTimers() until a hanging test passes. That treats the symptom, and it can be dangerous for recursive scheduling. A polling loop or heartbeat may schedule another timer every time one runs. Jest protects against an effectively infinite timer loop by aborting after a configured limit. Use the smallest advancement that expresses the scenario, or runOnlyPendingTimers when you intentionally want only the currently queued generation.

Separate macro-task advancement from promise settlement

Timer callbacks and promise reactions occupy different queues. The useful question is not whether code is “async.” Ask which operation creates the next unit of work and which Jest API allows it to settle.

Production stepScheduled byTest control
Delay before retrysetTimeoutAdvance virtual time
Promise returned by probePromise machineryAwait it or use an async timer API when created inside a timer
Repeating heartbeatsetIntervalAdvance a bounded interval or run only pending timers
Network responseExternal I/OStub transport or run controlled integration service
Animation callback in jsdomrequestAnimationFrameUse the documented frame or time advancement

Here is a debounced asynchronous save. The timer callback starts a promise, so synchronous advancement runs the callback but does not await completion of the save:

// src/debounced-save.ts
export function createDebouncedSave(
  save: (value: string) => Promise<void>,
  delayMs: number,
) {
  let timer: ReturnType<typeof setTimeout> | undefined;

  return (value: string): void => {
    if (timer !== undefined) {
      clearTimeout(timer);
    }

    timer = setTimeout(() => {
      void save(value);
    }, delayMs);
  };
}

The modern test can await timer advancement and then assert the mock promise-producing function was called:

// test/debounced-save.test.ts
import { createDebouncedSave } from '../src/debounced-save';

type Save = (value: string) => Promise<void>;

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

test('saves only the latest value after the debounce window', async () => {
  jest.useFakeTimers();
  const save = jest.fn<Save>().mockResolvedValue(undefined);
  const scheduleSave = createDebouncedSave(save, 300);

  scheduleSave('draft');
  scheduleSave('final');

  expect(jest.getTimerCount()).toBe(1);
  await jest.advanceTimersByTimeAsync(300);

  expect(save).toHaveBeenCalledTimes(1);
  expect(save).toHaveBeenCalledWith('final');
});

The mock's function signature matches the production dependency, so TypeScript checks both the injected callback and later assertions. This is one reason test compilation belongs in the migration gate. Runtime success alone cannot reveal every invalid mock contract.

Use virtual system time for calendar behavior

Modern fake timers can set the current system time without firing pending timers. That distinction lets you test code that reads Date.now() or constructs Date values without waiting for wall-clock time.

Suppose an access token is considered expired at or after its expiration timestamp:

// src/token-state.ts
export function isExpired(expiresAtMs: number): boolean {
  return Date.now() >= expiresAtMs;
}

A deterministic test freezes the clock at an unambiguous UTC instant:

// test/token-state.test.ts
import { isExpired } from '../src/token-state';

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

test('changes to expired exactly at the expiration instant', () => {
  jest.useFakeTimers();
  const expiration = new Date('2026-08-08T10:00:00.000Z').getTime();

  jest.setSystemTime(new Date('2026-08-08T09:59:59.999Z'));
  expect(isExpired(expiration)).toBe(false);

  jest.setSystemTime(new Date('2026-08-08T10:00:00.000Z'));
  expect(isExpired(expiration)).toBe(true);
});

Use ISO timestamps with a Z suffix when UTC is intended. A timestamp without an offset is interpreted in a local timezone, so the same test can represent different instants on developer laptops and CI workers.

Also remember that jest.setSystemTime changes the fake current time but does not itself cause due timers to execute. If the production behavior schedules a callback for expiration, set the starting time before scheduling, then advance timers by the desired duration.

Know when selective faking is the right repair

Modern fake timers replace a broad set of APIs by default. Occasionally, the unit under test or a dependency needs a real API while the test still wants virtual time elsewhere. The documented doNotFake option excludes selected APIs.

test('keeps performance measurements real while controlling timeouts', () => {
  jest.useFakeTimers({ doNotFake: ['performance'] });
  const callback = jest.fn();
  const before = performance.now();

  setTimeout(callback, 100);
  jest.advanceTimersByTime(100);

  const after = performance.now();
  expect(callback).toHaveBeenCalledTimes(1);
  expect(after).toBeGreaterThanOrEqual(before);
  jest.useRealTimers();
});

Do not add exclusions speculatively. Every real clock left inside a fake-clock test creates a mixed-time model that reviewers must understand. First identify the API causing the incompatibility, then decide whether the production boundary should be injected, the dependency should be mocked, or selective faking is the clearest option.

SymptomLikely clock mistakeTargeted response
Date-based assertion changes by machineLocal timezone or real Date leakedUse explicit UTC input and modern virtual time
Promise never settles after advanceTimersByTimeTimer callback schedules async workUse the corresponding async advancement API
runAllTimers abortsRecurring timer continually reschedulesAdvance a bounded duration or only current timers
Later test hangs unexpectedlyFake timers were not restoredAdd file-level cleanup
Library reads impossible durationFake performance conflicts with itConfirm and selectively exclude that API
Spy assertion fails after migrationLegacy timers were Jest mocksSpy explicitly only when call inspection is required

Migrate legacy suites in observable slices

A safe migration keeps the suite green while changing one behavior class at a time. Inventory legacy use first:

rg "legacyFakeTimers|useFakeTimers" test src
npx jest --listTests

The first command is a text inventory and may find comments or helper wrappers. The second is a documented Jest CLI operation that lists tests Jest would run. Do not use a runner flag from another framework. For a focused migration, Jest supports --testNamePattern and its -t alias.

Classify each file before editing:

File classTypical assumptionMigration action
Delay-only unitTimeout fires after advancementSwitch to modern and retain bounded advancement
Date-sensitive unitManual Date spy or wall clockUse setSystemTime with explicit UTC
Promise inside timerManual microtask flushing helperReplace with async timer advancement
Recursive schedulerRepeated runAllTimersUse bounded time or runOnlyPendingTimers
Timer spy testExpects setTimeout itself to be a Jest mockAdd jest.spyOn(global, 'setTimeout') if interaction matters
Integration testMixes fake time and real I/ORemove fake timers or isolate the scheduling boundary

Change one class, run its focused tests, then run the affected package suite. A representative command is:

npx jest test/wait-for-ready.test.ts --runInBand
npx jest --testNamePattern="returns true on the third asynchronous probe"
npm test

--runInBand is a documented Jest option and can simplify diagnosis by running serially. It should not become a permanent fix for state leakage. If serial execution passes and worker execution fails, investigate shared state, environment mutation, or resource conflicts.

Avoid enabling fake timers globally as the first migration step. Global enablement expands the affected surface to tests that never requested virtual time, including library setup code. Convert file by file, establish cleanup, then consider configuration only if nearly every test in a narrowly defined Jest project needs the same clock.

Diagnose the classic modern-timer hang

A realistic failure looks like this: a test calls waitForReady, advances 2,000 milliseconds synchronously, and then times out awaiting the result. The production function is correct, and increasing Jest's test timeout changes nothing.

The sequence explains the hang:

  1. waitForReady invokes the first asynchronous probe and immediately yields.
  2. The test advances virtual time before the probe's resolved promise continuation schedules the first timeout.
  3. No timer exists yet, so advancement does no useful work.
  4. The continuation later schedules a virtual timeout at the clock's current point.
  5. The test awaits the result without advancing that newly scheduled timer.

Diagnose it with temporary assertions, not random flush helpers:

test('shows when the retry timer enters the queue', async () => {
  jest.useFakeTimers();
  const probe = jest.fn().mockResolvedValue(false);

  const resultPromise = waitForReady(probe, {
    intervalMs: 1_000,
    maxAttempts: 2,
  });

  expect(jest.getTimerCount()).toBe(0);
  await Promise.resolve();
  expect(jest.getTimerCount()).toBe(1);

  await jest.advanceTimersByTimeAsync(1_000);
  await expect(resultPromise).resolves.toBe(false);
  jest.useRealTimers();
});

The single await Promise.resolve() lets the already resolved probe continuation run in this particular example. Do not turn it into a universal “flush promises” utility. Multiple promise layers, queueMicrotask, nextTick, and third-party schedulers can behave differently. Prefer awaiting a meaningful public signal or using Jest's async timer controls.

Increasing the test timeout cannot advance a frozen virtual clock. Switching back to real timers can hide the ordering bug while making the test slow. The repair is to model the queue transition accurately.

Test recursive and interval-based schedulers without exhausting them

Heartbeat code is intentionally infinite until stopped. runAllTimers is therefore the wrong semantic operation. Test a bounded number of ticks and assert cleanup:

// src/heartbeat.ts
export function startHeartbeat(
  send: () => void,
  intervalMs: number,
): () => void {
  const handle = setInterval(send, intervalMs);
  return () => clearInterval(handle);
}
// test/heartbeat.test.ts
import { startHeartbeat } from '../src/heartbeat';

test('sends two heartbeats and cancels the interval', () => {
  jest.useFakeTimers();
  const send = jest.fn();
  const stop = startHeartbeat(send, 1_000);

  jest.advanceTimersByTime(2_000);
  expect(send).toHaveBeenCalledTimes(2);
  expect(jest.getTimerCount()).toBe(1);

  stop();
  expect(jest.getTimerCount()).toBe(0);
  jest.useRealTimers();
});

This test verifies externally meaningful cadence and lifecycle. It avoids inspecting the numeric timer handle, which differs by environment and is not part of the helper's contract.

For a recursive setTimeout scheduler, runOnlyPendingTimers can fire the current generation without recursively draining every timer created by callbacks. Still assert the queue size so an accidental duplicate schedule is visible.

Design a CI matrix that detects migration drift

During a large migration, a temporary matrix can execute selected representative suites in both modes through a test helper. Do not try to pass an undocumented CLI switch for timer mode. Jest timer mode is selected through the API or configuration.

A small helper makes the selected mode explicit:

// test/support/use-project-timers.ts
export function useProjectTimers(): 'modern' | 'legacy' {
  if (process.env.JEST_TIMER_MODE === 'legacy') {
    jest.useFakeTimers({ legacyFakeTimers: true });
    return 'legacy';
  }

  jest.useFakeTimers();
  return 'modern';
}

Only shared behavior tests should use this bridge. Tests for modern-only features such as setSystemTime must select modern timers directly. A CI job can set JEST_TIMER_MODE to each allowed value and run a representative compatibility suite. Use shell braces when composing names, for example artifact_${JEST_TIMER_MODE}_${CI_NODE_INDEX}, so variable boundaries are explicit.

The compatibility matrix is temporary evidence. Remove the legacy leg when all production-supported tests use modern behavior. Maintaining two clock implementations forever doubles diagnosis paths and encourages new tests to depend on the old model.

Choose timer assertions that survive implementation changes

The strongest assertions describe user-visible time behavior: no call before the delay, one call at the threshold, latest input wins, expiration changes at an instant, stop prevents future heartbeats. Asserting that setTimeout received exactly one callback can be useful for scheduler libraries, but it is usually too internal for business logic.

AssertionContract strengthCoupling risk
Result changes after 300 msHigh when delay is specifiedLow
Latest debounced value is savedHighLow
Timeout called with 300MediumTied to scheduling mechanism
Timer handle equals a numberLowEnvironment-specific
No timers remain after stopHigh for lifecycleLow
Entire callback function snapshotVery lowExtremely brittle

If a refactor replaces a timeout with a scheduler abstraction but preserves the delay, behavioral tests should pass. Interaction tests should fail only when the choice of scheduler is itself part of the contract.

Modern fake timers are not automatically better for every test. Real timers remain appropriate when time is not the reason for the test, durations are tiny, and replacing global clock APIs would make integration behavior less representative. The decisive question is whether virtual time makes the relevant contract faster and more deterministic without creating a mixed-clock illusion.

Frequently Asked Questions

Are modern fake timers the default in current Jest?

Yes. In current Jest documentation, jest.useFakeTimers() installs the modern implementation. Legacy mode is selected explicitly with jest.useFakeTimers({ legacyFakeTimers: true }). Check documentation matching the major version pinned in your repository before migrating an older suite, because historical Jest versions used different defaults and accepted older invocation forms. Keep the selection visible in tests during migration, and avoid relying on transitive or global configuration that reviewers cannot see.

Why does advanceTimersByTime not finish my promise?

Synchronous timer advancement runs eligible timer callbacks, but promise work created by those callbacks may still need to settle. In modern mode, use the documented asynchronous counterpart when the scenario requires it, such as await jest.advanceTimersByTimeAsync(ms). Also verify that the timer was actually scheduled before advancing. A promise that depends on real network or filesystem I/O will not complete merely because virtual time moved. Diagnose the source of each queued operation rather than adding repeated generic flushes.

When should a suite remain on legacy fake timers?

Keep legacy mode only while a known compatibility constraint is being understood and scheduled for migration. Examples include tests that intentionally inspect replaced timer functions as Jest mocks or old helper utilities built around legacy queue behavior. Document the owning tests and an exit condition. New tests should not copy the legacy pattern. If a third-party dependency fails only under modern timers, first decide whether the integration test should use real timers or whether selective faking isolates the actual conflict.

Can fake timers make integration tests deterministic?

They can make a scheduling boundary deterministic, but they cannot virtualize an entire integration environment. Databases, browser navigation, HTTP servers, worker threads, and external queues continue to have their own clocks and completion signals. Mixing a frozen JavaScript clock with real services can create confusing timeouts and expired-token behavior. Prefer fake timers in focused units around scheduling logic. For broader integration tests, inject a clock where possible, control service data, and wait on observable readiness rather than assuming Jest's timer queue represents every dependency.