InterviewsVector
intermediateRare5 min read · Updated Jul 18, 2026

Implement clearAllTimers: Tracking and Patching Timer Globals

Implement clearAllTimers in JavaScript two ways — a scoped timer registry and a safe monkey-patch of setTimeout/setInterval — with the leak-hunting use case that motivates it.


The problem

The platform gives you clearTimeout(id) for one timer and no way to see "all currently pending timers." Implement clearAllTimers() that cancels every outstanding timeout and interval.

This is a design question wearing a utility costume: JavaScript exposes no timer registry, so you must build one — and the two ways to build it (a scoped manager vs patching the globals) have genuinely different trade-offs. Test runners solve exactly this problem for real: Jest and Vitest's fake timers are a monkey-patched timer registry, which is why this question shows up in tooling-flavored interviews.

Approach 1: a scoped timer manager (the clean one)

Own the creation path; never touch globals:

function createTimerManager() {
  const timeouts = new Set();
  const intervals = new Set();
 
  return {
    setTimeout(callback, delay, ...args) {
      const id = setTimeout(() => {
        timeouts.delete(id); // self-evict on fire — the registry stays accurate
        callback(...args);
      }, delay);
      timeouts.add(id);
      return id;
    },
 
    setInterval(callback, delay, ...args) {
      const id = setInterval(callback, delay, ...args);
      intervals.add(id); // intervals never self-evict — they don't end on their own
      return id;
    },
 
    clearTimeout(id)  { clearTimeout(id);  timeouts.delete(id); },
    clearInterval(id) { clearInterval(id); intervals.delete(id); },
 
    clearAllTimers() {
      timeouts.forEach((id) => clearTimeout(id));
      intervals.forEach((id) => clearInterval(id));
      timeouts.clear();
      intervals.clear();
    },
 
    get pendingCount() { return timeouts.size + intervals.size; },
  };
}

Design points to narrate:

  • Two sets, not one — calling both clearTimeout and clearInterval on every id "works" in browsers (they share an id pool) but is an implementation-detail bet; separate books also let you report what kind of work is pending.
  • Self-eviction on fire — without the delete inside the wrapper, the "registry" is a monotonically growing set of dead ids: a leak in the leak-detection tool.
  • pendingCount — one getter turns the manager into an observability tool ("why won't my test exit?"), which is the actual production motivation.

Limitation, stated honestly: it only sees timers created through it. Third-party code calling global setTimeout is invisible. Which forces approach 2 —

Approach 2: patching the globals (the powerful, dangerous one)

function installTimerTracking(target = globalThis) {
  const pending = new Set();
  const original = {
    setTimeout: target.setTimeout.bind(target),
    setInterval: target.setInterval.bind(target),
    clearTimeout: target.clearTimeout.bind(target),
    clearInterval: target.clearInterval.bind(target),
  };
 
  target.setTimeout = (cb, delay, ...args) => {
    const id = original.setTimeout(() => {
      pending.delete(id);
      cb(...args);
    }, delay);
    pending.add(id);
    return id;
  };
 
  target.setInterval = (cb, delay, ...args) => {
    const id = original.setInterval(cb, delay, ...args);
    pending.add(id);
    return id;
  };
 
  target.clearTimeout  = (id) => { pending.delete(id); original.clearTimeout(id); };
  target.clearInterval = (id) => { pending.delete(id); original.clearInterval(id); };
 
  return {
    clearAllTimers() {
      pending.forEach((id) => {
        original.clearTimeout(id);
        original.clearInterval(id);
      });
      pending.clear();
    },
    uninstall() { Object.assign(target, original); }, // ALWAYS ship the undo
  };
}

The safety rules that make a patch "safe" — each one is a follow-up in disguise:

  • Capture originals first, and bind them — some environments throw Illegal invocation when timer functions are called with the wrong this.
  • Preserve the contract exactly — same return type (numeric id), same ...args forwarding. Code you don't own will break on any deviation; that's what "transparent wrapper" means.
  • Provide uninstall — a patch without an undo is a landmine for the next test file. (This is also why real fake-timer libraries are scoped per test with automatic restore.)
  • Install before anyone captures references — code that did const st = setTimeout at module load bypasses the patch forever. Patch timing is the fundamental, unfixable caveat of monkey-patching, and naming it is the senior answer to "when does this break?"

Where this is real

  • Test isolation — "a test leaked a timer and the suite hangs / the next test flakes": pendingCount + clearAllTimers in afterEach is the diagnostic and the cure. (Then: use the framework's fake timers, which do this properly and let you time-travel.)
  • SPA teardown — widgets/mini-apps that must clean up everything on unmount; a scoped manager per widget instance is the disciplined version.
  • Sandboxing third-party embeds — hand the embed a wrapped environment so the host can bulk-revoke its scheduling.

Edge cases interviewers probe

  • A timer scheduling another timer while clearAllTimers runsSet.forEach does visit entries added during iteration, but the deeper answer: cancellation is synchronous and JS is single-threaded, so no timer fires mid-sweep; only re-entrant clearAllTimers from a wrapper callback is even conceivable. Walking that reasoning shows event-loop fluency.
  • Node vs browser ids — browsers return numbers; Node returns Timeout objects with unref(). A registry keyed "by id" must treat ids as opaque — the Set approach already does; a numeric-keyed object wouldn't.
  • clearAllTimers idempotence — second call must be a harmless no-op (cleared Sets make it so).
  • What it can't cancelrequestAnimationFrame, microtasks, in-flight fetches. "Timers" is one lane of pending work; bulk-cancelling async work is AbortController's job, and confusing the two lanes is a red flag.

Common mistakes

  • One Set + "call both clears on every id" without knowing it's relying on a shared id pool.
  • No self-eviction for fired timeouts — the registry becomes the leak.
  • Patching without capturing originals (infinite recursion the moment the wrapper calls target.setTimeout) — yes, this happens under interview pressure.
  • No uninstall, or patching after app code has cached references.
  • Presenting the manager as able to see all timers — scoping is its definition, not a bug, but you must say it.

Follow-up questions

  • "How do Jest's fake timers work?" — the patch approach, plus replacing the clock: callbacks queue in a virtual-time priority queue and advanceTimersByTime drains it deterministically. Build-your-own is a great extended exercise.
  • "Track rAF too?" — same wrapper pattern over requestAnimationFrame/cancelAnimationFrame; note it fires per-frame so self-eviction lands in the wrapper the same way.
  • "Pending-timer count as a health metric?" — export pendingCount and alert on monotonic growth: a live leak detector for setIntervals nobody cleared.
  • "Cancel-all for async work generally?" — one AbortController per scope, pass its signal to everything, abort() on teardown — the pattern that supersedes timer-only thinking.
  • "Why doesn't the platform expose the timer list?" — encapsulation across libraries (your bulk-clear would nuke their timers too — exactly the scoped-vs-global tension this question exists to surface).

  • Custom setInterval

    intermediate

    Implement setInterval with setTimeout recursion — and fix the drift problems of the native version.

  • Rebuild setTimeout on top of requestAnimationFrame to show you understand timer scheduling and drift.

  • Throttle

    beginner

    Implement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).

  • Debounce

    beginner

    Implement debounce from scratch, with leading/trailing options and the classic search-input use case.