Skip to content
JavaScript js browser 5 min read

Timers & Scheduling

JavaScript is single-threaded, so anything you want to run later — a delayed action, a repeating poll, a smooth animation, or low-priority background work — has to be scheduled onto the event loop. The browser gives you several scheduling primitives, and picking the right one matters: the wrong tool can make animations stutter, drain battery, or block user interaction. This page compares setTimeout/setInterval, requestAnimationFrame, and requestIdleCallback, and shows when to reach for each.

Classic timers: setTimeout and setInterval

setTimeout(fn, delay) runs a callback once after at least delay milliseconds. setInterval(fn, delay) runs it repeatedly every delay milliseconds. Both return a numeric id you can pass to clearTimeout / clearInterval to cancel.

The key word is at least. The delay is a minimum, not a guarantee — the callback only fires once the call stack is empty and the event loop reaches it. A heavy synchronous task will push your timer back.

const id = setTimeout(() => console.log("ran after ~1s"), 1000);
clearTimeout(id); // cancels it before it fires

let ticks = 0;
const timer = setInterval(() => {
  ticks += 1;
  console.log(`tick ${ticks}`);
  if (ticks === 3) clearInterval(timer);
}, 500);

Output:

tick 1
tick 2
tick 3

A setTimeout(fn, 0) does not run immediately — it defers fn until after the current synchronous code finishes, which is handy for breaking up work or letting the browser repaint.

Note: browsers clamp nested timeouts to a minimum of 4ms after 5 levels of nesting, and throttle timers in background tabs (often to once per second or slower). Never rely on a timer for precise, frame-accurate timing.

A subtle trap with setInterval is that it queues the next call on a fixed schedule regardless of how long the callback takes. If the callback runs longer than the interval, calls can stack up. A self-scheduling setTimeout chain avoids this by only scheduling the next run after the current one completes:

function poll() {
  doWork();
  setTimeout(poll, 1000); // next run starts 1s after this one ends
}
poll();

requestAnimationFrame: smooth animation

For anything that updates pixels on screen, use requestAnimationFrame (rAF). It asks the browser to run your callback right before the next repaint — typically 60 times per second (every ~16.7ms), but matched to the display’s actual refresh rate. The callback receives a high-resolution DOMHighResTimeStamp, so you can compute motion based on real elapsed time rather than assuming a fixed frame rate.

rAF automatically pauses in background tabs, which saves CPU and battery. Like the timers, it returns an id you cancel with cancelAnimationFrame.

<canvas id="c" width="320" height="120" style="border:1px solid #888"></canvas>
<button id="toggle">Pause / Resume</button>
<script>
  const canvas = document.getElementById("c");
  const ctx = canvas.getContext("2d");
  let x = 0, running = true, rafId;

  function frame(timestamp) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // move 100px per second using the timestamp for frame-rate independence
    x = (timestamp / 10) % canvas.width;
    ctx.fillStyle = "#4f46e5";
    ctx.beginPath();
    ctx.arc(x, 60, 20, 0, Math.PI * 2);
    ctx.fill();
    rafId = requestAnimationFrame(frame);
  }
  rafId = requestAnimationFrame(frame);

  document.getElementById("toggle").addEventListener("click", () => {
    running = !running;
    if (running) rafId = requestAnimationFrame(frame);
    else cancelAnimationFrame(rafId);
  });
</script>

Because rAF fires once per repaint, it is also the correct place to batch DOM reads and writes that affect layout — you do your work, the browser paints once, and you avoid the jank of animating with setInterval.

requestIdleCallback: low-priority background work

Some work is genuinely optional: prefetching data, sending analytics, lazy-building offscreen UI, or processing a large list in chunks. requestIdleCallback(fn) schedules such work for moments when the browser is idle — after it has finished rendering a frame and has spare time before the next one.

The callback receives a deadline object. Call deadline.timeRemaining() to see how many milliseconds you have, and deadline.didTimeout to detect that a fallback timeout option forced the run. Always check the budget and yield back if you are out of time, rescheduling the rest.

const queue = buildLargeTaskList(); // e.g. thousands of items

function processChunk(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && queue.length) {
    handleItem(queue.shift());
  }
  if (queue.length) {
    requestIdleCallback(processChunk, { timeout: 2000 });
  }
}
requestIdleCallback(processChunk, { timeout: 2000 });

The timeout option guarantees the callback eventually runs even if the browser never goes idle. Cancel a pending callback with cancelIdleCallback(id).

Warning: requestIdleCallback has good but not universal support (notably missing in some older Safari versions). Feature-detect and fall back to setTimeout when it is absent: const ric = window.requestIdleCallback ?? ((cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1));

Choosing the right tool

ToolFiresBest forPauses in background tab
setTimeoutOnce, after a delayDeferred one-off actions, debouncingThrottled
setIntervalRepeatedly, fixed periodPolling, clocks (prefer rAF for visuals)Throttled
requestAnimationFrameBefore each repaint (~60fps)Animation, layout-affecting DOM updatesYes
requestIdleCallbackWhen the browser is idleNon-urgent background workYes

In Node.js you also get setImmediate (run after the current poll phase) and process.nextTick (run before the event loop continues) — but requestAnimationFrame and requestIdleCallback are browser-only, since they are tied to rendering.

Here is a side-by-side demo showing how rAF stays smooth while a setInterval clock ticks independently:

<div id="box" style="width:40px;height:40px;background:#10b981;border-radius:6px"></div>
<p>Elapsed: <span id="clock">0</span>s</p>
<script>
  const box = document.getElementById("box");
  const clock = document.getElementById("clock");
  const start = performance.now();

  function animate(now) {
    const t = (now - start) / 1000;
    box.style.transform = `translateX(${Math.sin(t * 2) * 120 + 120}px)`;
    requestAnimationFrame(animate);
  }
  requestAnimationFrame(animate);

  setInterval(() => {
    clock.textContent = Math.floor((performance.now() - start) / 1000);
  }, 1000);
</script>

Best Practices

  • Use requestAnimationFrame for anything visual; never animate with setInterval, which is unaware of the display refresh rate.
  • Drive animation from the timestamp argument (or performance.now()) so motion is frame-rate independent across 60Hz, 120Hz, and throttled displays.
  • Always cancel timers and rAF/idle callbacks on cleanup (component unmount, page hide) to avoid leaks and stale work.
  • Prefer a self-scheduling setTimeout chain over setInterval when each iteration may take variable time.
  • Move non-urgent work to requestIdleCallback (with a timeout fallback) so it never competes with rendering or input.
  • Remember timers are clamped and throttled in background tabs — don’t depend on them for precise timing; use timestamps to measure real elapsed time.
Last updated June 1, 2026
Was this helpful?