ESC

Type to search the knowledge base.

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.

advanced6 min read
  • 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 on Date.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:

  1. target = now + ms.
  2. While the earliest job has due <= target, pop it, set now = due, invoke it.
  3. If that callback schedules a new timer whose due is still <= target, it runs in this tick too.
  4. When nothing due remains, set now = target so 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.
  • clearTimeout has 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 now on a quiet tick. Later ticks never catch up.
  • Running cleared ids. clearTimeout must remove from the queue, not mark a tombstone you still invoke.
  • runAll without a cap. Recursive zero-delay timers.
  • Patching globalThis.setTimeout inside 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 guides