Timers and Intervals
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.
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.
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.
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:
- Synced with display — runs right before the repaint, so updates are smooth
- Pauses in background — doesn't run when the tab is hidden (saves battery)
- 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);
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
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:
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)
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);
- 1setTimeout(fn, 0) doesn't run immediately — it runs after the current code and microtasks finish
- 2Use recursive setTimeout instead of setInterval to guarantee spacing between executions
- 3Use requestAnimationFrame for visual animations — never setInterval at 16ms
- 4requestIdleCallback is for low-priority work during idle periods
- 5Always clear timers when they're no longer needed to prevent memory leaks
| What developers do | What 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 |