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
setTimeoutandsetIntervalaren’t truly accurate timers—they specify minimum delays, not guarantees, due to the single-threaded event looprequestAnimationFrameautomatically pauses in background tabs and syncs with screen refresh rates, making it superior for animations oversetInterval- Recursive
setTimeoutprovides more control thansetIntervalfor 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.