What this usually means
Jest has two fake timer implementations: 'legacy' (the old lolex-based) and 'modern' (based on @sinonjs/fake-timers). The modern API uses a different internal queue and requires that any Promise microtasks triggered by timer callbacks are flushed before advancing further. The most common root cause is calling jest.advanceTimersByTime without awaiting a microtask flush (e.g., await Promise.resolve()), so the callback runs but its Promise chain hangs. Another classic: calling jest.useFakeTimers() after the code under test has already scheduled a real timer — the fake timer system doesn't retroactively capture those. Also, mixing fake timers with real async operations (like fetch) without jest.spyOn or mocks leaves those real timers pending.
The first ten minutes — establish facts before touching code.
- 1Check which timer API is active: console.log(jest.getTimerCount()) after a test step — if it's 0 but you expect a timer, fake timers aren't controlling.
- 2Add await new Promise(process.nextTick) after jest.advanceTimersByTime(1000) to flush microtasks — if the callback then fires, it's a microtask ordering issue.
- 3Swap jest.useFakeTimers() to jest.useFakeTimers('legacy') — if the test passes, you're hitting a known modern timer bug or missing microtask flush.
- 4Inspect the test file for jest.useFakeTimers placement: it must be called before any timer is scheduled (typically in beforeEach or at the top of the test).
- 5Run the test with --verbose and look for 'Timeout - Async callback was not invoked' — note the line number of the last assertion before timeout.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchTest files: search for jest.useFakeTimers() and check its position relative to the first setTimeout call.
- searchThe specific test's console output: add console.log('Timer count:', jest.getTimerCount()) after each timer advance.
- searchThe source file under test: look for setTimeout, setInterval, or setImmediate calls that might use a different reference (e.g., global.setTimeout vs window.setTimeout).
- searchjest.config.js or test setup file: check if timers: 'fake' is set globally or if resetMocks or clearMocks overrides timer state.
- searchNode.js version: in Node 14+, the modern fake timers changed behavior — upgrade jest to v27+ or use 'legacy' if stuck on Node 12.
Practical causes, not theory. These are the things you will actually find.
- warningUsing modern fake timers (jest.useFakeTimers()) without flushing microtasks after advancing — Promise callbacks queue on the microtask queue, not the timer queue.
- warningCalling jest.useFakeTimers() after some code has already run — e.g., in a describe block top-level instead of beforeEach, where module-level setTimeout already fired.
- warningMixing fake timers with real async I/O (like fetch) — the fetch's internal promise doesn't use fake timers and may never resolve.
- warningUsing jest.advanceTimersByTime(0) instead of jest.runAllTimers() when you need to fire pending timers immediately (advance does not fire if time doesn't change).
- warningLegacy timer API (jest.useFakeTimers('legacy')) and modern API (default) behave differently — code depending on legacy behavior breaks with modern.
- warningTimer callback throws an error silently — Jest catches errors in timers but the test still waits for the async callback to complete.
Concrete fix directions. Pick the one that matches your root cause.
- buildSwitch to async advance: await jest.advanceTimersByTimeAsync(1000) (Jest 28+) which automatically flushes microtasks.
- buildAfter jest.advanceTimersByTime, await a microtask flush: await Promise.resolve(); or await new Promise(process.nextTick).
- buildUse jest.runAllTimers() or jest.runOnlyPendingTimers() instead of advance when you don't need precise time.
- buildMove jest.useFakeTimers() to a beforeEach that runs before any test code, and call jest.useRealTimers() in afterEach.
- buildIf using modern timers with async, wrap timer callback returns in a Promise and await the advance: const promise = new Promise(r => setTimeout(r, 1000)); jest.advanceTimersByTime(1000); await promise;
- buildMock async I/O (like fetch) with jest.fn() that returns a resolved Promise so it doesn't introduce real timers.
A fix you cannot prove is a guess. Close the loop.
- verifiedRun the test with --detectOpenHandles to see if any handles (like timers) are keeping the process alive.
- verifiedAdd assertion after timer advance: expect(jest.getTimerCount()).toBe(0) to confirm all timers are consumed.
- verifiedLog a counter inside the timer callback: let count = 0; setTimeout(() => { count++; console.log(count); }, 100); jest.advanceTimersByTime(100); — verify count becomes 1.
- verifiedUse jest.useFakeTimers({ legacyFakeTimers: true }) to test both implementations and confirm fix works on both.
- verifiedIn CI, add a timeout of 10s and a custom error message: test('works', async () => { ... }, 10000) to catch hangs early.
Things that make this bug worse or harder to find.
- warningDon't call jest.useFakeTimers() inside an async test after an await — the await may have already consumed real timers.
- warningDon't use jest.advanceTimersByTime(0) to fire immediate timers — it doesn't advance time, so timers scheduled with setTimeout(fn, 0) won't fire.
- warningDon't assume jest.runAllTimers() fires all timers — it only fires currently pending timers; if a timer schedules another timer, you may need to loop.
- warningDon't mix real and fake timers in the same test — if you need real timers for some parts, isolate them in a separate test file.
- warningDon't forget to call jest.useRealTimers() in afterEach if you set fake timers globally — other tests may break.
- warningDon't use jest.useFakeTimers('legacy') as a blind fix without understanding — it may hide real timing issues in your code.
The Timeout That Wouldn't Die — Fake Timers Stall in CI
Timeline
- 09:15CI fails on a new test for a service that uses setTimeout to delay retries.
- 09:18Test passes locally with jest.useFakeTimers('legacy') but fails with default modern timers.
- 09:22I check jest.getTimerCount() — it's 1 before advance, 0 after, but callback didn't run.
- 09:25Add await Promise.resolve() after advance — test passes locally.
- 09:30CI still fails — turns out the test file uses jest.useFakeTimers() in describe top-level, but another test resets to real timers.
- 09:35Move useFakeTimers to beforeEach and add afterEach with useRealTimers.
- 09:40CI passes. Root cause: modern timers require microtask flush, and timer state leaked between tests.
I was adding a retry mechanism to an external API call. The service uses setTimeout to wait 5 seconds before retrying. My test called jest.useFakeTimers() in the describe block, then invoked the function, then called jest.advanceTimersByTime(5000). The test timed out every time in CI but passed locally — classic.
First, I checked the timer count. After advance, count dropped to 0, meaning the timer was consumed. But the callback never ran. I remembered that modern fake timers execute callback on the macrotask queue, but any Promise in that callback goes to the microtask queue. My assertion was awaiting a Promise that resolved inside the callback — but the microtask queue wasn't flushed. Adding await Promise.resolve() fixed it.
But CI still failed. That's when I discovered the test file had jest.useFakeTimers() at the top level of describe, and another test in the same file called jest.useRealTimers() in afterEach. By the time my test ran, fake timers were disabled. Moving the setup to beforeEach and adding a cleanup in afterEach solved it. The lesson: always scope timer fakes per test, and always flush microtasks after advancing modern timers.
Root cause
Mixed timer state between tests (leaked real timers) plus missing microtask flush after jest.advanceTimersByTime with modern fake timers.
The fix
Relocated jest.useFakeTimers() to beforeEach and added afterEach to reset to real timers; changed all timer advances to await jest.advanceTimersByTimeAsync(5000) (Jest 28+) or added await Promise.resolve() after each advance.
The lesson
Fake timers are not a simple swap-in — you must understand the event loop architecture. Modern timers require explicit microtask flushing, and timer state isolation between tests is non-negotiable.
Jest 26 introduced 'modern' fake timers based on @sinonjs/fake-timers, replacing the older 'legacy' implementation (lolex). The key difference: modern timers execute callbacks synchronously when time is advanced, but they queue any Promise reactions on the microtask queue. If your timer callback creates a Promise (e.g., async/await), that Promise's .then() will not run until you manually flush the microtask queue. Legacy timers did not have this separation — they executed everything synchronously, which is why many old tests pass with 'legacy' but break with modern.
To check which version your test uses, inspect jest.config.js or call jest.useFakeTimers('modern') explicitly. If you need to support both, wrap timer advances in a helper that awaits a microtask: const flushMicrotasks = () => new Promise(process.nextTick);. In Jest 28+, use jest.advanceTimersByTimeAsync which handles microtasks internally.
A common pitfall: jest.useFakeTimers() is called once in a describe block, but some other test calls jest.useRealTimers() in afterEach (or vice versa). This leaves the next test in an unpredictable state. The fix is to always pair jest.useFakeTimers() with jest.useRealTimers() in beforeEach/afterEach, never at describe top-level. Also, jest.resetAllMocks() does not reset timers — you must explicitly call jest.useRealTimers().
Pro tip: add a global setup file that checks timer state after each test: afterEach(() => { if (jest.getTimerCount() > 0) console.warn('Timers left: ' + jest.getTimerCount()); }); This catches leaks early. Another pattern: use jest.useFakeTimers({ timerLimit: 100 }) to prevent runaway timers.
When you call jest.advanceTimersByTime(1000), Jest executes all macrotask callbacks scheduled for that time (setTimeout, setInterval). If any of those callbacks are async and await a Promise, the microtask queue fills. But Jest only flushes the macrotask queue — the microtask queue remains until control returns to the event loop. So your test's await after advance() sees the Promise as pending, and the test hangs.
The fix: after advance, add await new Promise(process.nextTick) or await Promise.resolve(). This yields to the microtask queue. In Jest 28+, use jest.advanceTimersByTimeAsync(1000) which does both. If you can't upgrade, create a helper: const advanceAndFlush = (ms) => { jest.advanceTimersByTime(ms); return new Promise(resolve => setImmediate(resolve)); };. This flushes both macrotask and microtask queues.
setInterval with fake timers often surprises: calling jest.advanceTimersByTime(1000) fires the interval callback once, but the next interval is scheduled 1000ms from the original time, not from now. If you advance by 1000 again, it may fire again — but if you advance by 2000, it fires twice. Use jest.runOnlyPendingTimers() to fire all pending intervals at once, or loop advance until no timers remain.
Recursive setTimeout (a function that calls setTimeout(fn, delay) at the end) can cause an infinite loop if you use jest.runAllTimers(). Instead, advance incrementally or use jest.useFakeTimers({ timerLimit: 10 }) to cap execution. Always test with a small number of iterations first.
A test passes locally but fails in CI often due to environment differences — Node version, CPU speed, or Jest version. CI may run tests in parallel, causing timer state leaks across test files. Use --runInBand to serialise tests and see if that fixes it — if yes, it's a state leak. Also, CI might have a different default Jest config (e.g., resetMocks: true).
Log jest.getTimerCount() at the end of every test in CI. Add a custom reporter that prints timer count. If you see non-zero counts, you have unflushed timers. Also check for setImmediate usage — it's a real timer that fake timers don't mock by default unless you pass options: jest.useFakeTimers({ enableGlobal: true, toFake: ['setTimeout', 'setInterval', 'setImmediate'] }).
Frequently asked questions
Why does jest.advanceTimersByTime(0) not fire setTimeout(fn, 0)?
advanceTimersByTime advances the clock by the given number of milliseconds, then fires any timers scheduled at or before that new time. If you pass 0, the clock doesn't advance, so no timers fire. To fire immediate timers, use jest.runAllTimers() or jest.runOnlyPendingTimers(). Alternatively, advance by 1ms: jest.advanceTimersByTime(1).
Should I use jest.useFakeTimers('legacy') for my old tests?
Only as a temporary bandaid. The legacy API is deprecated and will be removed in Jest 30. Instead, update your tests to work with modern timers by flushing microtasks after every timer advance. The migration is mechanical: add await Promise.resolve() after each advance (or use advanceTimersByTimeAsync in Jest 28+).
How do I test code that uses both async/await and setTimeout?
Use modern fake timers with microtask flushing. Structure your test: jest.useFakeTimers(); const fn = async () => { await new Promise(r => setTimeout(r, 100)); }; const promise = fn(); jest.advanceTimersByTimeAsync(100); await promise;. This ensures the Promise resolves. Never mix real and fake timers — mock any I/O that uses real timers (like fetch).
My test passes with --watch but fails on first run — why?
Watch mode reuses the test environment, so timer state from a previous test may linger. The first run sees a clean environment, but if a test doesn't clean up timers, the next test inherits them. Add afterEach(() => { jest.useRealTimers(); }) in every test file. Also, check if any module-level code sets timers when imported — use dynamic imports or rewire to delay that code.