Custom setInterval
intermediateImplement setInterval with setTimeout recursion — and fix the drift problems of the native version.
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 platform gives you
clearTimeout(id)for one timer and no way to see "all currently pending timers." ImplementclearAllTimers()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.
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:
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.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 —
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:
bind them — some environments throw Illegal invocation when timer functions are called with the wrong this....args forwarding. Code you don't own will break on any deviation; that's what "transparent wrapper" means.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.)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?"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.)clearAllTimers runs — Set.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.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).requestAnimationFrame, 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.Set + "call both clears on every id" without knowing it's relying on a shared id pool.target.setTimeout) — yes, this happens under interview pressure.uninstall, or patching after app code has cached references.advanceTimersByTime drains it deterministically. Build-your-own is a great extended exercise.requestAnimationFrame/cancelAnimationFrame; note it fires per-frame so self-eviction lands in the wrapper the same way.pendingCount and alert on monotonic growth: a live leak detector for setIntervals nobody cleared.AbortController per scope, pass its signal to everything, abort() on teardown — the pattern that supersedes timer-only thinking.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.
Implement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).
Implement debounce from scratch, with leading/trailing options and the classic search-input use case.