---
title: "Fake-timer leakage in Jest: the flake nobody sees coming"
description: "Why a single jest.useFakeTimers() call can pollute the next test in the file, what the failure looks like, and the three-line afterEach that makes it go away for good."
author: remy-duthu
date: 2026-04-25
category: testing
readTimeMinutes: 6
featured: false
product: test-engine
coverImage: ../../assets/blog/fake-timer-leakage-in-jest.png
---

A Jest test that uses fake timers can fail a completely unrelated test five files away. The failure has no obvious connection to timers, the stack trace points at an `await` that should be safe, and the test passes in isolation. Welcome to fake-timer leakage.

We see this pattern often enough on [Mergify Test Engine](/product/test-engine) that it earned its own slot in our [flaky Jest catalog](/learn/flaky-tests/jest). Here is what is actually happening, why the obvious fix is incomplete, and the three lines of `afterEach` that solve it.

## What you see

```ts
// auth.test.ts
test("login redirects after timeout", () => {
  jest.useFakeTimers();
  setTimeout(() => redirectToLogin(), 5000);
  // assertion forgotten or removed during refactor
});

// next file in the same worker
test("getUser returns the logged-in user", async () => {
  const user = await getUser();
  expect(user).toBeDefined();
});
```

You wrote both tests. They both pass when you run them with `--runInBand --testPathPattern=auth.test.ts` or `--testPathPattern=user.test.ts`. Run the suite end to end, and `getUser` fails roughly 30% of the time with an assertion that does not match anything in its own file. The CI logs say `expect(user).toBeDefined()` is not the line that failed; instead some inner mock call inside the user module throws because `redirectToLogin` fired against a half-mocked fetch.

Three properties of this failure make it so frustrating:

- **It crosses test boundaries.** `auth.test.ts` finished. `user.test.ts` is now running. The failure looks like it is in `user.test.ts`, but the cause is in `auth.test.ts`.
- **It depends on test order.** Jest decides which worker runs which file. Two consecutive runs can produce two different file orderings, so the failure shifts.
- **It cannot reproduce locally.** Your laptop has more CPU headroom than the CI runner. The leaked timer fires before the next test starts, so the leak resolves before it can pollute anything.

## Why fake timers leak

`jest.useFakeTimers()` mutates a module-scoped singleton. There is one fake-timer state per Jest worker process. When you flip to fake timers in test A and never flip back, **every subsequent test in that worker runs with fake timers active**, including tests that never asked for them.

That on its own is not catastrophic. The catastrophic part is that the timers you scheduled in test A (your `setTimeout` callback for `redirectToLogin`) are still queued. Jest does not flush them. They sit in the timer queue waiting for either:

1. Someone to call `jest.advanceTimersByTime` or `jest.runAllTimers`, or
2. The fake-timer state to be torn down, which fires teardown handlers.

When test B calls `await getUser()`, the test runner advances the microtask queue. If `getUser` internally uses real timers (it does, it called `fetch` which uses real `setTimeout` for connection timeouts), the fake-timer state is now confused: real timer callbacks resolve, fake ones never do, and the order in which mocks observe calls is no longer deterministic. The test fails.

## The naive fix and why it is incomplete

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

This flips back to real timers, but it does not flush the queue. Any `setTimeout` callback that was already scheduled under fake timers is still in memory, waiting for an opportunity to run. If your code captures references to the scheduled callback (cleanup handlers, debounce promises), those references are still live and can fire on the next tick.

We have seen suites where this naive fix reduces the leak rate from 30% to 8% but does not eliminate it. The 8% comes from the queued callbacks executing during `useRealTimers`'s own teardown, where they touch state that the next test has just initialized.

## The fix that holds

```ts
afterEach(() => {
  jest.clearAllTimers();
  jest.useRealTimers();
});
```

`clearAllTimers` drops every queued timer before the timer system itself flips back. The order matters. Reverse it (`useRealTimers` first, then `clearAllTimers`) and you clear an already-empty real-timer queue, leaving the fake-timer queue intact.

If you want to be defensive about it, the safest shape is:

```ts
afterEach(() => {
  jest.clearAllTimers();
  jest.useRealTimers();
  jest.restoreAllMocks();
});
```

`restoreAllMocks` undoes any `jest.spyOn` you added in the test. Combined with `clearAllTimers`, this gives you a clean slate for the next test that has nothing to do with timers or mocks.

## Why not move to global setup?

You can. If your suite uses fake timers in most tests, configure them in `jest.config.js`:

```js
module.exports = {
  fakeTimers: { enableGlobally: true },
};
```

This makes fake timers the default everywhere. You opt out per test with `jest.useRealTimers()` when you genuinely need real timers (network tests, integration tests against real services). The `clearAllTimers` discipline still applies, but it is now consistent across the whole suite instead of conditional.

The trade-off is that integration tests get noticeably slower because anything using real timing semantics needs explicit opt-out. Most teams settle on per-file fake-timer activation with the `afterEach` pattern above.

## How Mergify catches this before you ship

Without instrumentation, fake-timer leakage looks like random noise. The test that fails is not the test that caused the failure, so manual triage usually blames the wrong code. Engineers spend an afternoon staring at a test that has nothing wrong with it.

Test Engine reruns the failing test in isolation on a fresh worker. When the test passes alone but fails under the original schedule, the dashboard tags it as **ordering-sensitive** and surfaces the file-level dependency. You see the actual culprit (in this case, `auth.test.ts`) on the same screen as the symptom. Quarantine kicks in automatically once the pattern is confirmed, so the merge queue keeps moving while you write the missing `afterEach`.

If you want to check whether your Jest suite has this pattern lurking, [point Mergify at your repo](/discovery). It works with `jest-junit` or any JUnit-compatible reporter, no plugin install required.

## More patterns like this

Fake-timer leakage is one of the eight patterns we cover in [the flaky-tests-in-Jest guide](/learn/flaky-tests/jest). The others are mostly variants of the same theme: state that crosses test boundaries because nothing reset it. Snapshot drift, module-mock hoisting, `test.concurrent` racing on shared module state, unhandled promises landing on the next test's microtask queue. Same shape, different surface.

The good news, at least for Jest: the pattern set is small and finite. Once you can name what you are looking at, the fix is usually a few lines.
