Skip to content

Timers and Intervals

beginner13 min read

Scheduling Work in the Future

JavaScript is single-threaded. It can only do one thing at a time. But the browser gives you APIs to schedule work for later — after a delay, at regular intervals, or right before the next screen paint. These APIs don't create separate threads. They schedule callbacks to run at specific points in the event loop.

Understanding exactly when these callbacks run — and why a 0ms timeout doesn't mean "right now" — is fundamental to writing correct async JavaScript.

Mental Model

Think of the event loop as a to-do list for a single worker. setTimeout is like writing "do X after at least 5 minutes" on the list. But the worker finishes their current task first, then checks the list. If they're in the middle of a 10-minute task when your 5-minute timer fires, X doesn't run until minute 10. Timer delays are minimums, not guarantees. The callback runs at least that long after you scheduled it, but possibly much later if the main thread is busy.

setTimeout — Run Once After a Delay

// Run a function after 2000ms (2 seconds)
const timerId = setTimeout(() => {
  console.log('2 seconds later');
}, 2000);

// Cancel before it fires
clearTimeout(timerId);

setTimeout returns an ID you can use to cancel the timer with clearTimeout.

The Zero-Delay Myth

console.log('first');

setTimeout(() => {
  console.log('timeout');
}, 0);

console.log('second');

// Output: first, second, timeout

A delay of 0 doesn't mean "run immediately." It means "run as soon as possible after the current synchronous code finishes and the call stack is empty." The callback goes into the task queue and waits for the event loop to pick it up. Any synchronous code on the call stack runs first.

Timer clamping: why 0ms is actually 1-4ms

Browsers enforce a minimum delay. The HTML spec says setTimeout with a delay less than 4ms (when nested more than 5 levels deep) gets clamped to 4ms. In practice, even a top-level setTimeout(fn, 0) has a minimum delay of about 1ms in most browsers. For nested timers (a setTimeout inside a setTimeout inside a setTimeout...), the minimum delay jumps to 4ms after the 5th nesting level. This is a deliberate throttling mechanism to prevent setTimeout(fn, 0) loops from consuming 100% of the CPU.

Quiz
What does setTimeout(fn, 0) actually mean?

setInterval — Run Repeatedly

// Run every 1000ms
const intervalId = setInterval(() => {
  console.log('tick');
}, 1000);

// Stop the interval
clearInterval(intervalId);

The Problem with setInterval

setInterval doesn't wait for the previous callback to finish. If your callback takes 300ms and your interval is 1000ms, that's fine. But if your callback takes 1200ms, the next invocation tries to run before the previous one finished, and the timings go haywire.

// If processData takes longer than 1000ms,
// invocations start overlapping or getting queued back-to-back
setInterval(() => {
  processData(); // what if this takes 1500ms?
}, 1000);

Recursive setTimeout — The Better Pattern

async function poll() {
  await processData(); // wait for this to finish
  setTimeout(poll, 1000); // THEN schedule the next run
}

poll();

With recursive setTimeout, the next call is always scheduled after the current one completes. This guarantees a minimum gap between invocations. It's the correct pattern for polling APIs, animations that depend on completion, and any interval-like behavior where execution time varies.

Quiz
What is the advantage of recursive setTimeout over setInterval?

requestAnimationFrame — Sync with the Screen

requestAnimationFrame (rAF) runs your callback right before the browser's next repaint. This is the correct tool for visual animations because it syncs with the display's refresh rate (usually 60fps = every ~16.7ms).

function animate() {
  // Update animation state
  element.style.transform = `translateX(${position}px)`;
  position += 2;

  if (position < 500) {
    requestAnimationFrame(animate);
  }
}

requestAnimationFrame(animate);

Why Not setTimeout for Animations?

// BAD — timer-based animation
setInterval(() => {
  element.style.left = position++ + 'px';
}, 16); // trying to match 60fps

// Problems:
// 1. Timer isn't synced with the display refresh — causes jank
// 2. Runs even when the tab is hidden — wastes battery
// 3. Timer clamping makes it inconsistent

requestAnimationFrame solves all of these:

  1. Synced with display — runs right before the repaint, so updates are smooth
  2. Pauses in background — doesn't run when the tab is hidden (saves battery)
  3. Consistent timing — gets a high-resolution timestamp as an argument
function animate(timestamp) {
  // timestamp is a DOMHighResTimeStamp (milliseconds since page load)
  const elapsed = timestamp - startTime;

  // Calculate position based on time, not frames
  const progress = Math.min(elapsed / duration, 1);
  element.style.transform = `translateX(${progress * 500}px)`;

  if (progress < 1) {
    requestAnimationFrame(animate);
  }
}

const startTime = performance.now();
const duration = 2000; // 2 seconds
requestAnimationFrame(animate);

Canceling rAF

const rafId = requestAnimationFrame(callback);
cancelAnimationFrame(rafId);
Quiz
Why is requestAnimationFrame better than setInterval for visual animations?

requestIdleCallback — Run When the Browser Is Free

requestIdleCallback schedules work during idle periods — when the browser has no user interactions, animations, or layout work to do. It's for low-priority tasks that shouldn't delay important UI work.

requestIdleCallback((deadline) => {
  // deadline.timeRemaining() — ms of idle time left in this frame
  // deadline.didTimeout — true if the timeout expired

  while (deadline.timeRemaining() > 0) {
    doLowPriorityWork();
  }
}, { timeout: 2000 }); // optional: force execution after 2 seconds

Good uses for requestIdleCallback:

  • Sending analytics events
  • Pre-computing search indexes
  • Lazy-loading non-critical resources
  • Logging and telemetry
Browser support for requestIdleCallback

requestIdleCallback is supported in Chrome, Edge, and Firefox but not in Safari. For Safari, you can polyfill it with setTimeout(fn, 0), though it won't have the same idle-detection behavior.

Timer Ordering Summary

When multiple timer types are scheduled, they run in this order:

Execution Trace
Synchronous code
Runs first, immediately on the call stack
console.log, variable assignments, function calls
Microtasks
Promise.then, queueMicrotask drain completely before the next step
Always run before any macrotask
requestAnimationFrame
Runs right before the browser paints the next frame
Only fires when a repaint is needed
Macrotasks (timers)
setTimeout/setInterval callbacks run one per event loop iteration
The 'task queue' in the event loop
requestIdleCallback
Runs when the browser has spare time in a frame
May not run for a while if the browser is busy
requestIdleCallback(() => console.log('idle'));
setTimeout(() => console.log('timeout'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('promise'));
console.log('sync');

// Output (typical):
// sync
// promise
// rAF (before next paint)
// timeout
// idle (when browser is free)
rAF vs setTimeout ordering

The exact ordering of requestAnimationFrame relative to setTimeout(fn, 0) can vary between browsers and depends on when in the frame the code runs. Don't rely on a specific order between rAF and zero-delay timers. If you need guaranteed ordering, use Promises for "right after current code" and rAF specifically for visual updates.

Cleaning Up Timers

Always clear timers when they're no longer needed. Forgotten timers are a common source of memory leaks and bugs.

// Always save the ID and clear when done
const timeoutId = setTimeout(fn, 5000);
const intervalId = setInterval(fn, 1000);
const rafId = requestAnimationFrame(fn);

// Cleanup
clearTimeout(timeoutId);
clearInterval(intervalId);
cancelAnimationFrame(rafId);
Key Rules
  1. 1setTimeout(fn, 0) doesn't run immediately — it runs after the current code and microtasks finish
  2. 2Use recursive setTimeout instead of setInterval to guarantee spacing between executions
  3. 3Use requestAnimationFrame for visual animations — never setInterval at 16ms
  4. 4requestIdleCallback is for low-priority work during idle periods
  5. 5Always clear timers when they're no longer needed to prevent memory leaks
What developers doWhat they should do
Using setInterval for animations at 16ms intervals
setInterval doesn't sync with the browser's repaint cycle, causing janky animations. rAF fires at exactly the right time, pauses in background tabs, and provides timestamps for smooth time-based animation
Using requestAnimationFrame which syncs with the display refresh
Using setInterval for tasks where execution time varies
setInterval doesn't wait for the callback to complete — if execution takes longer than the interval, callbacks pile up. Recursive setTimeout only schedules the next run after the current one finishes
Using recursive setTimeout to guarantee spacing between runs
Not clearing timers when a component unmounts or is no longer needed
Forgotten timers continue running, wasting CPU and potentially accessing stale references (detached DOM nodes, removed elements). Always clear timers in cleanup code
Saving timer IDs and calling clearTimeout/clearInterval on cleanup