Implement a Fake setTimeout
A timer queue you can run in tests: schedule, clear, sort by due time, and flush without a real clock. Why a busy while-loop is a bad host.
- javascript
- timers
- testing
- interview
A candidate writes while (Date.now() < deadline) {} and calls it fake setTimeout. The tab freezes. No other timer runs. clearTimeout cannot interrupt a busy spin. You did not fake a clock. You stalled the only thread JavaScript gives you.
The job is a virtual clock plus a queue of due times. Tests call tick(ms) or runAll() to pump callbacks. Time jumps. The call stack stays free between pumps. That is what Jest and Vitest fake timers do, and it is the API you should expose in an interview.
Real setTimeout is a host macrotask, not an engine feature. Your fake is a tiny host. It does not need to match the HTML 4ms nested-timeout clamp unless the interviewer asks.
Key takeaways
- Store
{ id, due, fn, args }, sort by due time then insertion. Do not spin onDate.now().tick(ms)advances the virtual clock and runs every job due in that window, including jobs scheduled by those callbacks.runAll()drains the queue. Cap it;setTimeout(fn, 0)in a loop never ends.- A sync pump runs every callback on one stack. Microtasks wait until the pump returns unless you flush them.
A sorted queue of due times, then a pump
Build a factory so tests do not patch globalThis unless they want to.
function createFakeTimers() {
let now = 0;
let nextId = 1;
const queue = []; // { id, due, fn, args }
function sort() {
queue.sort((a, b) => a.due - b.due || a.id - b.id);
}
function setTimeout(fn, delay = 0, ...args) {
if (typeof fn !== 'function') {
throw new TypeError('setTimeout callback must be a function');
}
const ms = Number(delay);
const wait = Number.isFinite(ms) && ms > 0 ? ms : 0;
const id = nextId++;
queue.push({ id, due: now + wait, fn, args });
sort();
return id;
}
function clearTimeout(id) {
const i = queue.findIndex((job) => job.id === id);
if (i !== -1) queue.splice(i, 1);
}
function runDue(limit) {
sort();
while (queue.length && queue[0].due <= limit) {
const job = queue.shift();
now = job.due;
job.fn(...job.args);
}
}
function tick(ms) {
const add = Number(ms);
const target = now + (Number.isFinite(add) && add > 0 ? add : 0);
runDue(target);
now = target;
}
function runAll(maxJobs = 1000) {
let n = 0;
while (queue.length) {
if (++n > maxJobs) {
throw new Error('runAll: exceeded max jobs (possible recursive timer)');
}
const job = queue.shift();
now = job.due;
job.fn(...job.args);
}
}
return {
setTimeout,
clearTimeout,
tick,
runAll,
now: () => now,
};
}
Usage in a test-shaped script:
const timers = createFakeTimers();
const log = [];
timers.setTimeout(() => log.push('a'), 30);
const b = timers.setTimeout(() => log.push('b'), 10);
timers.setTimeout(() => log.push('c'), 10);
timers.clearTimeout(b);
timers.tick(10);
// log: ['c'] (b cleared; a still waiting)
timers.tick(20);
// log: ['c', 'a']
Ids start at 1 and only go up. Reusing ids after clearTimeout is how you cancel the wrong job later. Same-due timers run in schedule order (id as tie-break), which matches “the one you registered first.”
How the pump should work
tick(ms) is the important method. Procedure:
target = now + ms.- While the earliest job has
due <= target, pop it, setnow = due, invoke it. - If that callback schedules a new timer whose
dueis still<= target, it runs in this tick too. - When nothing due remains, set
now = targetso leftover delay is preserved.
That last step matters. setTimeout(fn, 100) then tick(40) then tick(60) must fire. If you never move now on empty ticks, the second tick still thinks the clock is 0.
runAll() keeps popping until the queue is empty. Nested setTimeout(fn, 0) is an infinite loop. A max-jobs guard is not optional.
const timers = createFakeTimers();
function ping() {
timers.setTimeout(ping, 0);
}
ping();
// timers.runAll(); // throws after the cap
tick(0) runs jobs due now, not every future job. People confuse it with runAll.
Why a busy while is a bad host
// Do not do this.
function blockingTimeout(fn, ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
/* spin */
}
fn();
}
Problems stacked in one loop:
- It uses the real clock, so a 5s timer takes 5s of wall time. Tests stay slow.
- It occupies the call stack the whole wait. Clicks, paints, and other timers cannot run. See the event loop.
clearTimeouthas no chance. You already entered the spin.- Multiple timers cannot interleave by due time. You can only block for one delay.
A queue plus tick jumps 30s in a single turn. That is the point of fake timers for debounce.
Microtasks, intervals, and what you can skip
In a browser, each timer callback is its own macrotask. After it returns, all microtasks drain before the next timer. A for/while pump calls every due fn on the same stack. Promise.then work queued inside the first callback waits until tick returns.
const timers = createFakeTimers();
timers.setTimeout(() => {
Promise.resolve().then(() => console.log('micro'));
console.log('timer');
}, 0);
timers.setTimeout(() => console.log('later'), 0);
timers.runAll();
// timer, later, then micro (all timers already ran)
If the interviewer cares, flush microtasks between jobs (await Promise.resolve() in an async tick, or a drained queueMicrotask chain). That is the difference between Vitest advanceTimersByTime and advanceTimersByTimeAsync. Do not pretend a sync pump is a full HTML event loop. Point at queueMicrotask vs setTimeout.
setInterval is the same queue with a reschedule after each fire, unless clearInterval ran. Skip it until they ask. HTML’s minimum delay and nesting clamp are extra credit, not the core.
Do not implement this by calling real setTimeout(fn, 0) in a loop until Date.now() passes. That is still the real clock with extra jitter.
Footguns
- Unsorted insert. A 5ms job registered after a 100ms job must still run first.
- Forgetting to advance
nowon a quiettick. Later ticks never catch up. - Running cleared ids.
clearTimeoutmust remove from the queue, not mark a tombstone you still invoke. runAllwithout a cap. Recursive zero-delay timers.- Patching
globalThis.setTimeoutinside the factory and never restoring. Prefer returning{ setTimeout, tick, runAll }and let the test install it. Pair with clear all timeouts if they want a host object.
Interview answer (out loud)
“I would not busy-wait on Date.now. I keep a virtual now and a list of jobs with due times, sorted by due then id. setTimeout pushes a job and returns an id. clearTimeout removes it. tick(ms) jumps the clock and runs every job due in that window, including timers those callbacks schedule. runAll flushes the queue with a safety cap so a zero-delay loop cannot hang the test. I would mention that a sync pump does not drain microtasks between timers the way the real event loop does.”
Further reading
Related on this site
- Clear all timeouts
- Timeout a promise
- The event loop
- queueMicrotask vs setTimeout
- Fake timers for debounce
- Debounce implementation
Related guides
- Clear All TimeoutsTrack timer ids you created and clear them as a set. Monkey-patching the global is a test smell; a small TimerHost is the production shape.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.