How setInterval Works in JavaScript: Architecture
Understand how JavaScript setInterval works internally. Learn interval scheduling, drift problems, self-correcting timers, hidden tab throttling, and when to choose setTimeout recursion over setInterval.
setInterval schedules a callback function to fire repeatedly at a fixed interval. Unlike setTimeout which fires once, setInterval keeps firing until you explicitly cancel it. This guide covers how intervals are scheduled, why they drift, the precise difference between setInterval and recursive setTimeout, and patterns for building accurate repeating timers.
Basic Syntax
// setInterval(callback, intervalMs, ...args)
const intervalId = setInterval(callback, interval, arg1, arg2);
clearInterval(intervalId); // Stop it| Parameter | Type | Description |
|---|---|---|
callback | Function | Called repeatedly at each interval |
interval | Number | Milliseconds between each call |
...args | Any | Extra arguments forwarded to callback |
| Returns | Number | Interval ID used with clearInterval |
let count = 0;
const id = setInterval(() => {
count++;
console.log(`Tick ${count}`);
if (count === 5) {
clearInterval(id); // Stop after 5 ticks
console.log("Stopped");
}
}, 1000);
// Tick 1 (after ~1s)
// Tick 2 (after ~2s)
// ...
// Tick 5 (after ~5s)
// StoppedHow setInterval Schedules Callbacks
Like setTimeout, setInterval is managed by the browser's Web API, not by JavaScript's single-threaded engine. The interval fires in the task queue (macrotask queue):
setInterval(fn, 1000) registered at T=0
T=1000ms: fn queued in task queue
-> event loop checks: call stack empty? yes -> run fn
-> fn runs (takes 0ms)
T=2000ms: fn queued again
T=3000ms: fn queued again
...
The key point: the interval clock ticks independently of how long the callback takes.
The Drift Problem
If the callback takes longer than the interval, the queue fills up faster than it can drain:
// Interval: 1000ms, but callback takes 1500ms
let lastRun = Date.now();
setInterval(() => {
const now = Date.now();
console.log(`Gap since last run: ${now - lastRun}ms`);
lastRun = now;
// Simulate 1500ms of work
const start = Date.now();
while (Date.now() - start < 1500) {}
}, 1000);
// Actual output (approximate):
// Gap since last run: 1000ms
// Gap since last run: 1500ms <-- first callback ran long
// Gap since last run: 1500ms <-- interval couldn't fire on timeHow Browsers Handle Queued Intervals
Different browsers have different policies:
| Scenario | Browser Behavior |
|---|---|
| Callback finishes before next interval | Fires at the next interval normally |
| Callback overruns the interval | Some browsers skip the overrun tick |
| Multiple ticks queue up | At most one extra tick is queued (browsers coalesce) |
The spec-compliant behavior: if the timer fires and a previous execution of the same interval is still running (waiting to start), the new callback is put in the queue but only ONE pending callback is allowed to queue at a time.
setInterval vs Recursive setTimeout
This is a fundamental difference to understand:
// setInterval schedules the NEXT tick at a FIXED time from the ORIGINAL start
setInterval(() => {
doWork(); // If this takes 200ms, the NEXT call starts shorter than 1000ms later
}, 1000);
// Recursive setTimeout schedules the NEXT call after THIS call finishes
function scheduleRecursive() {
doWork(); // If this takes 200ms...
setTimeout(scheduleRecursive, 1000); // NEXT run starts 1000ms from NOW
}
setTimeout(scheduleRecursive, 1000);Timing Diagrams
setInterval(fn, 1000) where fn takes 200ms:
T=0: [fn runs 200ms]
T=1000: [fn runs 200ms]
T=2000: [fn runs 200ms]
|<--- 800ms gap --->|<--- 800ms gap --->|
(interval was 1000ms but fn itself took 200ms)
Recursive setTimeout(fn, 1000) where fn takes 200ms:
T=0: [fn runs 200ms] ... 1000ms wait ...
T=1200: [fn runs 200ms] ... 1000ms wait ...
T=2400: [fn runs 200ms] ... 1000ms wait ...
|<--- 1000ms gap --->|<--- 1000ms gap --->|
(gap between END of one run and START of next is always 1000ms)
Which to Use
| Scenario | Best Choice |
|---|---|
| Fixed visual updates (clock display) | setInterval |
| Polling where each call can take variable time | Recursive setTimeout |
| You need a guaranteed gap between executions | Recursive setTimeout |
| Simple countdown or progress | setInterval |
Building a Self-Correcting Timer
setInterval drifts over time because each tick has small time errors that accumulate. A self-correcting timer compensates:
function createAccurateInterval(callback, interval) {
const start = performance.now();
let expectedTick = start + interval;
let timerId = null;
function tick() {
callback();
const now = performance.now();
const drift = now - expectedTick;
expectedTick += interval;
// Adjust next delay to compensate for drift
const nextDelay = Math.max(0, interval - drift);
timerId = setTimeout(tick, nextDelay);
}
timerId = setTimeout(tick, interval);
return {
stop() {
clearTimeout(timerId);
}
};
}
const clock = createAccurateInterval(() => {
console.log(new Date().toLocaleTimeString());
}, 1000);
// Stays accurate even after several minutesThe Accumulating Clock
A common real-world example: a stopwatch timer that stays accurate:
function createStopwatch() {
let running = false;
let elapsed = 0;
let startTime = null;
let intervalId = null;
function format(ms) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const centiseconds = Math.floor((ms % 1000) / 10);
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}`;
}
return {
start() {
if (running) return;
running = true;
startTime = Date.now() - elapsed;
intervalId = setInterval(() => {
elapsed = Date.now() - startTime;
// Use actual Date.now() difference, not accumulated interval ticks
console.log(format(elapsed));
}, 10); // Update every 10ms (centisecond precision)
},
stop() {
if (!running) return;
clearInterval(intervalId);
running = false;
},
reset() {
clearInterval(intervalId);
running = false;
elapsed = 0;
startTime = null;
},
getTime() {
return elapsed;
}
};
}
const sw = createStopwatch();
sw.start();
setTimeout(() => { sw.stop(); console.log("Final:", sw.getTime()); }, 3000);Avoiding Common Mistakes
Mistake 1: Forgetting to Save the ID
// LEAKY: No way to stop this interval
setInterval(() => {
console.log("Running forever...");
}, 1000);
// FIX: Always store the ID
const id = setInterval(() => {
console.log("Stoppable");
}, 1000);
// Later:
clearInterval(id);Mistake 2: Using setInterval for Animations
// WRONG: setInterval for animation
let x = 0;
setInterval(() => {
element.style.transform = `translateX(${x++}px)`;
}, 16); // ~60fps but imprecise and not synced to display
// RIGHT: requestAnimationFrame for animation
function animate() {
element.style.transform = `translateX(${x++}px)`;
if (x < 300) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);Mistake 3: Interval Leaks in Components
// LEAKY React-like pattern
function setupWidget() {
const id = setInterval(updateUI, 1000);
// If the component is destroyed without clearInterval, the interval leaks
}
// FIX: Always return cleanup function
function setupWidget() {
const id = setInterval(updateUI, 1000);
return () => clearInterval(id); // Cleanup function
}
const cleanup = setupWidget();
// When widget is destroyed:
cleanup();Interval Throttling in Background Tabs
Like setTimeout, setInterval callbacks are throttled in hidden browser tabs (typically to 1-second minimum intervals):
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
console.log("Tab hidden: intervals will throttle to ~1s minimum");
} else {
console.log("Tab visible: intervals run at normal speed");
}
});
// For critical intervals (e.g., WebSocket keepalive), use Web Workers
// which are NOT throttled by tab visibilityPolling With setInterval
setInterval is the natural choice for polling:
function createPoller(fetchFn, intervalMs = 5000) {
let id = null;
let active = false;
return {
start() {
if (active) return;
active = true;
fetchFn(); // Run immediately on start
id = setInterval(fetchFn, intervalMs);
},
stop() {
clearInterval(id);
id = null;
active = false;
},
isActive() {
return active;
}
};
}
const statusPoller = createPoller(async () => {
const res = await fetch("/api/status");
const data = await res.json();
console.log("Status:", data.status);
}, 10000);
statusPoller.start();
// Later:
statusPoller.stop();Rune AI
Key Insights
- Independent clock: The interval timer runs independently of callback duration, so slow callbacks cause gaps shorter than the specified interval
- Fixed-time vs fixed-gap:
setIntervaltargets a fixed rate from start, recursivesetTimeoutguarantees a fixed gap after each execution - Self-correct with performance.now(): Measure actual elapsed time and adjust the next delay to compensate for accumulated drift
- Always clear on cleanup: Store every interval ID and call
clearIntervalwhen the component or feature is destroyed to avoid memory leaks - Background throttling: Intervals in hidden tabs fire at most once per second; use Web Workers for time-critical work that must run in the background
Frequently Asked Questions
Does setInterval guarantee exactly N ms between calls?
What happens if I call setInterval inside setInterval?
How many setInterval timers can I have running simultaneously?
Why does clearInterval(id) not work after a page navigation?
Is setInterval part of JavaScript or the browser?
Conclusion
| Feature | setInterval(fn, N) | Recursive setTimeout(fn, N) |
|---|---|---|
| Timing reference | Fixed from original call | Fixed from END of last execution |
| Drift behavior | Accumulates small errors | Maintains gap after execution |
| Can skip ticks | Yes (if callback runs long) | No (next call is always after gap) |
| Cancel method | clearInterval(id) | clearTimeout(id) |
| Use for | Clocks, polling, animation | API polling, retry loops |
setInterval fires a callback repeatedly at a fixed interval by scheduling it as a macrotask through the browser Web API. The interval clock is independent of callback execution duration, which is why slow callbacks cause drift. For accurate repeating work, measure actual elapsed time with performance.now() and self-correct. Always store interval IDs and call clearInterval to prevent memory leaks. |
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.