JavaScript Web APIs: setTimeout, setInterval, requestAnimationFrame

JavaScript runs on a single-threaded event loop, which means timing operations can't truly 'pause' execution. Instead, `setTimeout`, `setInterval`, and `requestAnimationFrame` schedule callbacks to...

Key Insights

  • setTimeout and setInterval aren’t truly accurate timers—they specify minimum delays, not guarantees, due to the single-threaded event loop
  • requestAnimationFrame automatically pauses in background tabs and syncs with screen refresh rates, making it superior for animations over setInterval
  • Recursive setTimeout provides more control than setInterval for repeated tasks and avoids the timing drift problem where callbacks queue up faster than they execute

Understanding JavaScript’s Timing Mechanisms

JavaScript runs on a single-threaded event loop, which means timing operations can’t truly “pause” execution. Instead, setTimeout, setInterval, and requestAnimationFrame schedule callbacks to run after the current call stack clears. The specified delay is a minimum wait time, not a guarantee—if the main thread is busy, your callback waits.

This fundamental constraint shapes how these APIs behave. A setTimeout(callback, 0) doesn’t execute immediately; it queues the callback to run after current synchronous code finishes. Understanding this event loop behavior is critical for writing reliable timing-dependent code.

setTimeout: Delayed Execution

setTimeout schedules a one-time callback execution after a specified delay in milliseconds.

// Basic syntax
const timeoutId = setTimeout(() => {
  console.log('Executed after 2 seconds');
}, 2000);

// Passing arguments to the callback
setTimeout((name, role) => {
  console.log(`${name} is a ${role}`);
}, 1000, 'Alice', 'developer');

// Cancel before execution
clearTimeout(timeoutId);

The function returns a timeout ID that you can use to cancel execution with clearTimeout. Always store this ID if there’s any chance you’ll need to cancel.

A classic mistake involves using setTimeout in loops with var:

// Wrong: All callbacks log 5
for (var i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i); // Logs 5, five times
  }, 100);
}

// Correct: Use let for block scoping
for (let i = 0; i < 5; i++) {
  setTimeout(() => {
    console.log(i); // Logs 0, 1, 2, 3, 4
  }, 100);
}

// Alternative: Use an IIFE to capture the value
for (var i = 0; i < 5; i++) {
  (function(index) {
    setTimeout(() => {
      console.log(index);
    }, 100);
  })(i);
}

With var, the closure captures the reference to i, not its value. By the time callbacks execute, the loop has finished and i equals 5. Using let creates a new binding for each iteration.

setInterval: Repeated Execution

setInterval executes a callback repeatedly with a fixed delay between executions.

let count = 0;
const intervalId = setInterval(() => {
  count++;
  console.log(`Tick ${count}`);
  
  if (count >= 5) {
    clearInterval(intervalId);
    console.log('Stopped');
  }
}, 1000);

The critical issue with setInterval is timing drift. If your callback takes longer than the interval to execute, callbacks queue up:

// Problematic: callbacks can pile up
let executionCount = 0;
setInterval(() => {
  executionCount++;
  console.log(`Start execution ${executionCount}`);
  
  // Simulate slow operation (150ms)
  const start = Date.now();
  while (Date.now() - start < 150) {}
  
  console.log(`End execution ${executionCount}`);
}, 100); // Interval shorter than execution time

This creates a backlog. The browser won’t execute multiple callbacks simultaneously, but they queue up, leading to callbacks running back-to-back without the intended delay.

Recursive setTimeout solves this:

let executionCount = 0;

function recursiveTask() {
  executionCount++;
  console.log(`Start execution ${executionCount}`);
  
  // Simulate slow operation
  const start = Date.now();
  while (Date.now() - start < 150) {}
  
  console.log(`End execution ${executionCount}`);
  
  // Schedule next execution AFTER this one completes
  if (executionCount < 5) {
    setTimeout(recursiveTask, 100);
  }
}

setTimeout(recursiveTask, 100);

Now the 100ms delay happens after each execution completes, preventing queue buildup.

requestAnimationFrame: Smooth Animations

requestAnimationFrame (rAF) synchronizes callbacks with the browser’s repaint cycle, typically 60 times per second (16.67ms intervals).

function animate() {
  // Animation logic here
  const element = document.getElementById('box');
  let position = parseInt(element.style.left || 0);
  element.style.left = (position + 2) + 'px';
  
  // Continue animation loop
  requestAnimationFrame(animate);
}

// Start animation
requestAnimationFrame(animate);

Comparing setTimeout to requestAnimationFrame:

// setTimeout approach - not synced with repaints
let position = 0;
function animateWithTimeout() {
  position += 2;
  document.getElementById('box1').style.left = position + 'px';
  setTimeout(animateWithTimeout, 16); // Approximate 60fps
}

// requestAnimationFrame - synced with browser
let position2 = 0;
function animateWithRAF() {
  position2 += 2;
  document.getElementById('box2').style.left = position2 + 'px';
  requestAnimationFrame(animateWithRAF);
}

The rAF version automatically pauses when the tab is inactive, saving CPU. It also ensures your animation runs at exactly the refresh rate—no faster, no slower.

Cancel animations with cancelAnimationFrame:

let animationId;

function animate() {
  // Animation logic
  animationId = requestAnimationFrame(animate);
}

animationId = requestAnimationFrame(animate);

// Stop animation
cancelAnimationFrame(animationId);

For frame-independent animations (consistent speed across different refresh rates), use delta time:

let lastTimestamp = 0;
let position = 0;

function animate(timestamp) {
  // Calculate time elapsed since last frame
  const deltaTime = timestamp - lastTimestamp;
  lastTimestamp = timestamp;
  
  // Move 100 pixels per second, regardless of frame rate
  const speed = 100; // pixels per second
  position += (speed * deltaTime) / 1000;
  
  document.getElementById('box').style.left = position + 'px';
  
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

Practical Comparison and Use Cases

Debouncing with setTimeout - delay execution until user stops typing:

function debounce(callback, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => callback.apply(this, args), delay);
  };
}

const searchInput = document.getElementById('search');
const debouncedSearch = debounce((value) => {
  console.log('Searching for:', value);
  // API call here
}, 300);

searchInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

Polling with setInterval - check server status periodically:

function startPolling(callback, interval) {
  const intervalId = setInterval(async () => {
    try {
      const response = await fetch('/api/status');
      const data = await response.json();
      callback(data);
    } catch (error) {
      console.error('Polling failed:', error);
    }
  }, interval);
  
  return () => clearInterval(intervalId);
}

// Usage
const stopPolling = startPolling((status) => {
  console.log('Server status:', status);
}, 5000);

// Stop when needed
// stopPolling();

Smooth scrolling with requestAnimationFrame:

function smoothScrollTo(targetY, duration) {
  const startY = window.scrollY;
  const distance = targetY - startY;
  const startTime = performance.now();
  
  function scroll(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    
    // Easing function
    const easeProgress = progress * (2 - progress);
    
    window.scrollTo(0, startY + distance * easeProgress);
    
    if (progress < 1) {
      requestAnimationFrame(scroll);
    }
  }
  
  requestAnimationFrame(scroll);
}

// Scroll to 1000px over 500ms
smoothScrollTo(1000, 500);

Best Practices and Common Pitfalls

Memory leaks happen when you forget to clear timers:

// Memory leak - interval never cleared
function startCounter() {
  let count = 0;
  setInterval(() => {
    count++;
    console.log(count);
  }, 1000);
} // intervalId lost, can't be cleared

// Correct approach
function startCounter() {
  let count = 0;
  const intervalId = setInterval(() => {
    count++;
    console.log(count);
  }, 1000);
  
  return () => clearInterval(intervalId);
}

const stopCounter = startCounter();
// Later: stopCounter();

React cleanup is essential:

import { useEffect, useState } from 'react';

function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    
    // Cleanup function
    return () => clearInterval(intervalId);
  }, []); // Empty deps - runs once on mount
  
  return <div>Count: {count}</div>;
}

Background tab throttling: Browsers throttle timers in inactive tabs to save resources. setTimeout and setInterval may only fire once per second in background tabs. requestAnimationFrame stops entirely. If you need accurate timing regardless of tab visibility:

// Use Web Workers for background timing
const worker = new Worker('timer-worker.js');

worker.onmessage = (e) => {
  console.log('Tick from worker:', e.data);
};

// timer-worker.js
setInterval(() => {
  postMessage(Date.now());
}, 1000);

Choosing the Right API

Use setTimeout for one-time delayed execution, debouncing, or throttling. Use recursive setTimeout instead of setInterval for repeated tasks where you need guaranteed delays between executions. Reserve setInterval for simple cases where timing drift doesn’t matter.

Use requestAnimationFrame for any visual animation or DOM manipulation that needs smooth rendering. It’s the only option that guarantees synchronization with the browser’s repaint cycle and automatically optimizes for performance.

The event loop doesn’t guarantee precise timing, but understanding these APIs’ strengths lets you build responsive, efficient applications.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.