Freeze Time and Fast-Forward in Jest
The clock-control playbook I keep on a sticky note: freeze `Date.now`, advance pending timers without sleeping, and write tests for debounce/throttle helpers that actually finish in milliseconds.
By @rohanbakr
May 2, 2026
·
Updated May 20, 2026
193 views
2
4.6 (8)
// The pattern: never call Date.now() or setTimeout directly inside the unit
// under test. Inject a clock and a setTimeout-like fn. The test owns time.
function makeClock(start = 0) {
let now = start;
const queue = []; // { fireAt, fn }
return {
now: () => now,
setTimeout(fn, ms) {
const fireAt = now + ms;
const handle = { fireAt, fn };
queue.push(handle);
queue.sort((a, b) => a.fireAt - b.fireAt);
return handle;
},
clearTimeout(handle) {
const i = queue.indexOf(handle);
if (i !== -1) queue.splice(i, 1);
},
advance(ms) {
const target = now + ms;
while (queue.length && queue[0].fireAt <= target) {
const next = queue.shift();
now = next.fireAt;
next.fn();
}
now = target;
},
};
}
// Unit under test: a debounce that uses the injected clock.
function debounce(fn, wait, clock) {
let timer = null;
return function debounced(...args) {
if (timer) clock.clearTimeout(timer);
timer = clock.setTimeout(() => fn(...args), wait);
};
}
const clock = makeClock();
const calls = [];
const debounced = debounce((x) => calls.push(x), 100, clock);
debounced('a');
debounced('b');
clock.advance(50);
console.log('after 50ms:', calls); // []
debounced('c'); // resets the timer
clock.advance(99);
console.log('after 99ms more:', calls); // []
clock.advance(1);
console.log('after 100ms total since c:', calls); // ['c']The fundamental move is dependency injection: the unit under test receives a clock object instead of reaching for the global Date.now and setTimeout. In production you wire it to globalThis; in tests you wire it to a fake whose advance method walks the queue deterministically. This is exactly what jest.useFakeTimers() does under the hood, but writing a 30-line clock once teaches you why the flaky tests happen: any code that reads time without going through the injected clock will desync from the test. The test for our debounce now finishes in microseconds and never relies on setTimeout(0) tricks.
// What jest.useFakeTimers('modern') gives you, simulated here in plain JS.
// In a real test file, replace `globalClock` calls with `jest.advanceTimersByTime`.
const globalClock = (() => {
let now = 1_700_000_000_000;
const queue = [];
return {
now: () => now,
schedule(fn, ms) {
const handle = { fireAt: now + ms, fn };
queue.push(handle);
queue.sort((a, b) => a.fireAt - b.fireAt);
return handle;
},
cancel(h) { const i = queue.indexOf(h); if (i !== -1) queue.splice(i, 1); },
advanceBy(ms) {
const target = now + ms;
while (queue.length && queue[0].fireAt <= target) {
const h = queue.shift();
now = h.fireAt;
h.fn();
}
now = target;
},
runAll() {
while (queue.length) {
const h = queue.shift();
now = h.fireAt;
h.fn();
}
},
};
})();
// Patch the globals our unit-under-test reads.
const realDateNow = Date.now;
const realSetTimeout = setTimeout;
Date.now = globalClock.now;
globalThis.setTimeout = (fn, ms) => globalClock.schedule(fn, ms);
globalThis.clearTimeout = (h) => globalClock.cancel(h);
// Unit under test: a session expiry helper using the standard globals.
function makeSession(ttlMs) {
const expiresAt = Date.now() + ttlMs;
return {
isValid: () => Date.now() < expiresAt,
scheduleRefresh(cb) {
return setTimeout(cb, ttlMs - 1000);
},
};
}
const session = makeSession(60_000);
console.log('valid at start:', session.isValid()); // true
let refreshed = false;
session.scheduleRefresh(() => { refreshed = true; });
globalClock.advanceBy(58_999);
console.log('refreshed @58.999s:', refreshed); // false
globalClock.advanceBy(1);
console.log('refreshed @59s:', refreshed); // true
globalClock.advanceBy(2_000);
console.log('valid after expiry:', session.isValid()); // false
// Restore globals so this stage does not leak into anything later.
Date.now = realDateNow;
globalThis.setTimeout = realSetTimeout;This is what jest.useFakeTimers('modern') actually does: it monkey-patches Date.now, setTimeout, setInterval, and a handful of other timer globals with a fake whose internal queue your test drives. Reproducing the technique in plain JS makes the failure modes obvious: anything that captured a reference to the real setTimeout before you patched (a vendored library, a closure formed at import time) is invisible to your fake clock and will flake. In real Jest you fix that with jest.isolateModules or by importing the unit-under-test after the fake-timers call. Always restore originals at the end so other tests do not run against a frozen clock.
// The trickiest case: a function that mixes setTimeout with awaited promises.
// Advancing time alone is not enough; you also have to flush the microtask queue.
function makeClock(start = 0) {
let now = start;
const queue = [];
return {
now: () => now,
setTimeout(fn, ms) {
const h = { fireAt: now + ms, fn };
queue.push(h);
queue.sort((a, b) => a.fireAt - b.fireAt);
return h;
},
async advanceAndFlush(ms) {
const target = now + ms;
while (queue.length && queue[0].fireAt <= target) {
const h = queue.shift();
now = h.fireAt;
h.fn();
// Yield so any awaited promise inside the callback can resolve
// before we look at the queue again.
await Promise.resolve();
await Promise.resolve();
}
now = target;
},
};
}
// Unit under test: poll an endpoint until it succeeds, with backoff.
async function pollUntilOk(checkFn, { intervalMs, clock }) {
return new Promise((resolve) => {
const tick = () => {
checkFn().then((ok) => {
if (ok) resolve('done');
else clock.setTimeout(tick, intervalMs);
});
};
tick();
});
}
(async () => {
const clock = makeClock();
let attempts = 0;
const check = async () => { attempts++; return attempts >= 3; };
const promise = pollUntilOk(check, { intervalMs: 1000, clock });
// Microtask flush so the first check runs.
await Promise.resolve(); await Promise.resolve();
console.log('attempts after first tick:', attempts); // 1
await clock.advanceAndFlush(1000);
console.log('attempts after 1s:', attempts); // 2
await clock.advanceAndFlush(1000);
console.log('attempts after 2s:', attempts); // 3
const result = await promise;
console.log('result:', result); // 'done'
})();The thing nobody tells you about jest.advanceTimersByTime is that it does not flush the microtask queue. If your callback does something().then(next), the timer fires and queues a microtask, but the next continuation only runs once the JS event loop pumps. In real Jest you sprinkle await Promise.resolve() between advances, or use jest.advanceTimersByTimeAsync (which yields internally). The pattern in the example, advance plus two yields, is the same recipe applied to the fake clock above. Once you see this you stop being surprised that attempts is one behind what you expected.
