InterviewsVector
advancedRare5 min read · Updated Jul 18, 2026

Build setTimeout on requestAnimationFrame

Rebuild setTimeout with requestAnimationFrame in JavaScript — timer scheduling, why delays are minimums, background-tab behavior, and never calling back synchronously.


The problem

Without using setTimeout/setInterval, implement mySetTimeout(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.

Implementation

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.
  • Forwarding ...args — real setTimeout(fn, ms, a, b) passes a, b to fn; most reimplementations forget the third-argument form exists.
  • The cancelled flag and cancelAnimationFramerafId 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.
  • Zero/negative delays stay async — the first check happens next frame, never synchronously. "Optimizing" 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.

Dry run (delay = 50, 60Hz display)

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.7ms

That 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 —

What this rebuild teaches about the native one

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:

  • Nested-timer clamping — timers nested ≥5 deep get a ~4ms minimum; a setTimeout(fn, 0) chain ticks at ~4ms, not 0.
  • Background tabs — native timers are clamped to ≥1s (or batched harder); rAF stops entirely when the tab is hidden. So this implementation freezes in background tabs — a real behavioral difference, and naming it unprompted is the senior move. (It also makes rAF-based scheduling desirable for animation work: no wasted cycles offscreen.)
  • Timer ids — native returns an integer id keyed to clearTimeout; returning an object with clear() is a nicer API but a contract deviation worth flagging when asked for a "polyfill" versus a "utility."

Edge cases interviewers probe

  • Cancel after fireclear() post-callback must be a harmless no-op (it is: cancelAnimationFrame on a dead id does nothing).
  • A throwing callback — with the call as the last statement, the rAF chain is already finished; nothing to clean up. If you add post-call bookkeeping, a try/finally becomes necessary — say so.
  • Many concurrent timers — N live timers = N rAF callbacks per frame. Native maintains one sorted timer heap. The "productionize" answer: one shared rAF loop scanning a min-heap of deadlines — a nice data-structures escalation.
  • Sub-frame delaysdelay: 5 still waits a full frame; you cannot beat the frame quantum with rAF.
  • Node.js — no rAF at all; the exercise is browser-specific (Node's equivalent primitives are setImmediate/process.nextTick, with their own ordering rules).

Common mistakes

  • Synchronous 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.
  • Cancel that only calls cancelAnimationFrame(rafId) — misses the reassignment race without the flag.
  • Believing this gains precision over native timers — it's a teaching rebuild, not an upgrade; its resolution is strictly worse.
  • Not knowing rAF pauses in hidden tabs, then proposing rAF timers for background polling.

Follow-up questions

  • "Build setInterval on top of this." — recursive re-arming, plus the drift question that comes with it.
  • "One rAF loop driving N timers?" — shared scheduler + min-heap by deadline; O(log n) insert, O(1) peek — and now you've re-derived how engines implement timers.
  • "When is rAF the right scheduler?" — anything that paints: animations, scroll-linked effects. It fires right before layout/paint, auto-pauses offscreen, and matches the display's refresh rate — throttling by frame instead of by milliseconds.
  • "Promise-based delay?"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).
  • "Why does setTimeout(fn, 0) still yield to promise callbacks?" — macrotask vs microtask ordering: the event loop — the theory question this whole exercise is secretly rehearsing.

  • Custom setInterval

    intermediate

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

  • Throttle

    beginner

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

  • clearAllTimers

    intermediate

    Track and clear every active timeout and interval — a utility question about monkey-patching globals safely.

  • Debounce

    beginner

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