Fake-timer leakage in Jest: the flake nobody sees coming
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.
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 Insights that it earned its own slot in our flaky Jest catalog. Here is what is actually happening, why the obvious fix is incomplete, and the three lines of afterEach that solve it.
What you see
// 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.tsfinished.user.test.tsis now running. The failure looks like it is inuser.test.ts, but the cause is inauth.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:
- Someone to call
jest.advanceTimersByTimeorjest.runAllTimers, or - 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
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
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:
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:
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 Insights 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. 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. 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.