InterviewsVector
intermediateCommon5 min read · Updated Jul 18, 2026

Build setInterval with Recursive setTimeout (and Fix Drift)

Implement setInterval via recursive setTimeout in JavaScript, understand overlap vs drift trade-offs, and build the drift-corrected scheduler clocks and pollers need.


The problem

Implement mySetInterval(callback, interval) using only setTimeout, with a working clear — then explain what your version does differently from native setInterval, because the differences are the question.

This is a scheduling-semantics question dressed as a polyfill. There are three possible contracts, and strong candidates name all three:

  1. Native setInterval — fires on a fixed schedule regardless of how long the callback takes; if the callback is slower than the interval, executions queue up and can run back-to-back (and in bad cases, effectively overlap via queued timers).
  2. Recursive setTimeout — schedules the next run after the current one finishes: guaranteed spacing, no pile-up, but each cycle's duration = interval + callback time, so the schedule drifts.
  3. Drift-corrected recursive setTimeout — recomputes each delay against an absolute schedule: no pile-up and no long-term drift. What clocks and pollers actually want.

Implementation (contract 2 — the standard answer)

function mySetInterval(callback, interval, ...args) {
  let timerId = null;
  let cancelled = false;
 
  function schedule() {
    timerId = setTimeout(() => {
      callback(...args);
      if (!cancelled) schedule(); // re-arm AFTER the callback completes
    }, interval);
  }
 
  schedule();
 
  return {
    clear() {
      cancelled = true;      // covers "clear() called from inside the callback"
      clearTimeout(timerId); // covers the currently pending timer
    },
  };
}

Why both cancellation mechanisms: timerId changes every cycle. If clear() is called while the callback is executing, the pending timer id is already stale and the recursive schedule() hasn't run yet — the flag is what stops the re-arm. Interviewers test precisely this by calling clear from inside the callback.

The drift, quantified

interval = 1000, callback takes ~40ms
native setInterval:      fires at 1000, 2000, 3000, ...      (fixed schedule)
recursive setTimeout:    fires at 1000, 2040, 3080, 4120 ... (interval + work, compounding)

After an hour, contract 2 is ~2.4 minutes behind. Fine for "poll every second-ish"; disqualifying for a clock display or anything aligned to wall time.

Implementation (contract 3 — the drift-corrected version)

Anchor to an absolute schedule and compute each delay as "time until the next planned tick":

function preciseInterval(callback, interval) {
  let timerId = null;
  let cancelled = false;
  const start = performance.now();
  let tick = 0;
 
  function schedule() {
    tick += 1;
    const nextAt = start + tick * interval;       // absolute target, no accumulation
    const delay = Math.max(0, nextAt - performance.now());
 
    timerId = setTimeout(() => {
      callback();
      if (!cancelled) schedule();
    }, delay);
  }
 
  schedule();
  return { clear() { cancelled = true; clearTimeout(timerId); } };
}

Each delay self-corrects: run 30ms late this cycle and the next delay is 30ms shorter. Error stays bounded instead of compounding — the same trick game loops and metronomes use. One decision to state out loud: if the callback takes longer than the interval, Math.max(0, ...) makes late ticks fire immediately back-to-back (catch-up). The alternative — skipping missed ticks (tick = Math.ceil((now - start) / interval)) — is right for UI clocks, wrong for counters. Which one you want is a requirements question; noticing that it's a fork is the senior signal.

Edge cases interviewers probe

  • Callback slower than the interval — the fork above: native queues, recursive spaces out, corrected must choose catch-up vs skip.
  • clear from within the callback — the flag + stale-id interplay; the planted test.
  • A throwing callback — in the recursive version, a throw kills the loop (the re-arm never runs) — arguably a feature (fail fast) or a bug (silent stop); native setInterval keeps firing. Wrap in try/finally with the re-arm in finally if the loop must survive errors — and decide, don't default.
  • interval of 0 or negative — stays async (timer clamp), never a synchronous spin; and nested-timer clamping (~4ms after 5 levels) puts a floor under tiny intervals.
  • Background tabs — timers clamp to ≥1s; a drift-corrected clock built on performance.now() recovers correctly on return, a naive one doesn't. Pair with visibilitychange for anything user-visible.

Common mistakes

  • clear that only does clearTimeout(timerId) — the re-arm race leaves the interval running.
  • Firing the callback synchronously when interval <= 0 — and in the widely circulated "enhanced" variant, also never scheduling the loop, so the "interval" runs exactly once. Two bugs in four lines.
  • Claiming recursive setTimeout "fixes drift" — it fixes overlap; it makes drift worse. The corrected version fixes drift. Keeping the two claims straight is half the question.
  • Not forwarding ...args (native setInterval(fn, ms, a, b) does).
  • Using Date.now() for the schedule anchor — wall-clock adjustments corrupt the whole schedule; performance.now() is monotonic.

Follow-up questions

  • "When would you actually prefer native setInterval?" — short-lived, cheap callbacks where the fixed cadence matters and pile-up can't realistically happen; it's also the only one visible to some devtools/test fakes without extra wiring.
  • "Build a poller that backs off when the tab is hidden." — recursive structure makes per-cycle delay dynamic for free: read document.visibilityState when computing the next delay — this is why pollers use contract 2/3, never native.
  • "An async callback — interval between starts or between completions?" — recursive setTimeout + await callback() gives between-completions; between-starts needs the absolute schedule. Same fork as catch-up-vs-skip, now with promises (series execution thinking).
  • "Test the drift correction?" — fake timers with a mocked performance.now: simulate a 30ms-late tick and assert the next delay shrank by 30ms — testing the invariant, not timestamps.
  • "Rebuild this on requestAnimationFrame?" — see custom setTimeout; rAF gives paint-aligned ticks and free pausing offscreen — the right base for animation loops, the wrong one for background polling.

  • clearAllTimers

    intermediate

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

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