Custom setInterval
intermediateImplement setInterval with setTimeout recursion — and fix the drift problems of the native version.
Rebuild setTimeout with requestAnimationFrame in JavaScript — timer scheduling, why delays are minimums, background-tab behavior, and never calling back synchronously.
Without using
setTimeout/setInterval, implementmySetTimeout(callback, delay, ...args)with a working cancel.
The only other clock a browser gives you is requestAnimationFrame — a callback per rendered frame (~16.7ms at 60Hz). So the exercise is: poll the elapsed time each frame, fire when the deadline passes. That's 15 lines. The question exists for what building it forces you to articulate: why delay is a minimum and not a promise, what rAF actually schedules, and why a timer API must never call back synchronously.
function mySetTimeout(callback, delay, ...args) {
const start = performance.now(); // monotonic — immune to system clock changes
let rafId = null;
let cancelled = false;
function tick(now) {
if (cancelled) return;
if (now - start >= delay) {
callback(...args); // native setTimeout also forwards extra args
} else {
rafId = requestAnimationFrame(tick); // not time yet — check again next frame
}
}
rafId = requestAnimationFrame(tick);
return {
clear() {
cancelled = true; // belt: covers a cancel DURING the same frame
cancelAnimationFrame(rafId); // suspenders: cancels the scheduled check
},
};
}Details that carry the marks:
performance.now() — monotonic and passed to rAF callbacks for free (now parameter). Date.now() follows the wall clock: an NTP adjustment mid-wait would make your timer fire wildly early or late....args — real setTimeout(fn, ms, a, b) passes a, b to fn; most reimplementations forget the third-argument form exists.cancelled flag and cancelAnimationFrame — rafId is reassigned every frame; a stale id plus an in-flight frame callback means cancellation needs both the flag and the cancel call to be airtight.delay <= 0 into a direct callback() makes the API sometimes-sync/sometimes-async — the Zalgo bug (why that's poison) — and native setTimeout(fn, 0) is always async for exactly this reason.frame 1 (t≈16.7) 50-16.7 not elapsed → re-arm
frame 2 (t≈33.3) not elapsed → re-arm
frame 3 (t≈50.0) elapsed? 50 >= 50 → fire
actual fire time: first FRAME after the deadline — resolution is ~16.7msThat quantization is the honest limitation: an rAF-based timer can't be more precise than the frame rate. Which sets up the real teaching point —
Every JavaScript delay is "no sooner than," never "exactly at." Your version fires at the first frame after the deadline; native fires at the first event-loop turn after the deadline where the macrotask queue reaches it. A long synchronous task delays both identically. Plus native quirks worth reciting:
setTimeout(fn, 0) chain ticks at ~4ms, not 0.clearTimeout; returning an object with clear() is a nicer API but a contract deviation worth flagging when asked for a "polyfill" versus a "utility."clear() post-callback must be a harmless no-op (it is: cancelAnimationFrame on a dead id does nothing).try/finally becomes necessary — say so.delay: 5 still waits a full frame; you cannot beat the frame quantum with rAF.setImmediate/process.nextTick, with their own ordering rules).callback() for delay <= 0 (Zalgo — the planted bug in most "enhanced" versions).Date.now() instead of the monotonic clock, with no awareness of the difference.cancelAnimationFrame(rafId) — misses the reassignment race without the flag.const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) — two lines with native, and an abortable version is the better interview flex (AbortSignal-aware sleep).setTimeout(fn, 0) still yield to promise callbacks?" — macrotask vs microtask ordering: the event loop — the theory question this whole exercise is secretly rehearsing.Implement setInterval with setTimeout recursion — and fix the drift problems of the native version.
Implement throttle and know exactly when to reach for it instead of debounce (scroll, resize, mousemove).
Track and clear every active timeout and interval — a utility question about monkey-patching globals safely.
Implement debounce from scratch, with leading/trailing options and the classic search-input use case.