setTimeout vs setInterval
The Kitchen Timer and the Drifting Metronome
LinkedIn Hook
setInterval(fn, 1000)does NOT fire every 1000ms.It fires every 1000ms from the moment the previous callback was scheduled — not from when it finished. And if your callback takes 300ms to run, that drift quietly accumulates. After 10 minutes of a "1 second" timer, you might be 400ms off and not know why.
This is the classic setInterval drift problem — and almost nobody studies it until an interviewer asks "why would you use recursive setTimeout instead of setInterval?"
In this lesson you'll learn how setTimeout and setInterval actually work under the event loop, why setInterval drifts, how to build a drift-free recursive setTimeout, why
setTimeout(fn, 0)isn't zero, and the 4ms clamping rule the HTML spec enforces after 5 nested timers.If you've ever written a polling loop, a retry with exponential backoff, or a "every second" UI tick — this lesson fixes the bugs hiding in your code.
Read the full lesson -> [link]
#JavaScript #InterviewPrep #EventLoop #Timers #Frontend #CodingInterview #WebDevelopment
What You'll Learn
- How
setTimeoutandsetIntervalinteract with the event loop and task queue - Why
setIntervaldrifts and how recursivesetTimeoutfixes it - The behavior of
setTimeout(fn, 0)and the 4ms nested-clamping rule
The Kitchen Timer vs The Drifting Metronome
Think of setTimeout like setting a kitchen egg timer — it rings once after a set delay, and you're done. setInterval is like a metronome — it ticks at a fixed interval, over and over. But here's the catch: the metronome can drift.
How setTimeout Works
setTimeout schedules a function to run once after a minimum delay. The key word is "minimum" — JavaScript's event loop only guarantees the callback will be added to the task queue after the delay, not that it will execute exactly at that moment.
console.log("Start");
setTimeout(() => {
console.log("Timeout callback");
}, 1000);
console.log("End");
// Output:
// Start
// End
// Timeout callback (after ~1000ms)
The callback goes to the Web API timer, then to the task queue, then waits for the call stack to be empty.
How setInterval Works
setInterval repeatedly calls a function at a fixed interval:
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Tick ${count}`);
if (count === 5) {
clearInterval(intervalId);
console.log("Stopped");
}
}, 1000);
The Drift Problem with setInterval
This is the critical problem that interviewers love. setInterval schedules callbacks at fixed intervals regardless of how long the callback takes to execute. If your callback takes 300ms to run and your interval is 1000ms, the next callback fires 1000ms after the previous one was scheduled, not after it finished.
Even worse, if the callback takes longer than the interval, executions can stack up or be skipped entirely:
// Demonstrating drift with setInterval
let expected = Date.now() + 1000;
const driftDemo = setInterval(() => {
const drift = Date.now() - expected;
console.log(`Drift: ${drift}ms`);
expected += 1000;
// Simulate variable work (0-300ms)
const start = Date.now();
while (Date.now() - start < Math.random() * 300) {}
}, 1000);
// After 10 seconds, you'll see drift accumulating:
// Drift: 2ms
// Drift: 15ms
// Drift: 34ms
// Drift: 67ms <-- drift keeps growing!
setTimeout(() => clearInterval(driftDemo), 10000);
Recursive setTimeout — The Better Alternative
Instead of setInterval, use recursive setTimeout. This guarantees the delay starts AFTER the previous callback completes:
// Recursive setTimeout — no drift accumulation
function accurateInterval(callback, delay) {
let timerId;
function tick() {
callback();
timerId = setTimeout(tick, delay);
}
timerId = setTimeout(tick, delay);
// Return cancel function
return () => clearTimeout(timerId);
}
// Usage
const cancel = accurateInterval(() => {
console.log("Tick at", new Date().toISOString());
// Even if this takes 300ms, next tick starts
// 1000ms after THIS tick finishes
}, 1000);
// Stop after 5 seconds
setTimeout(cancel, 5000);
setTimeout(fn, 0) Behavior
setTimeout(fn, 0) does NOT execute immediately. It defers the callback to the next iteration of the event loop, after the current call stack clears:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2
// Why? Synchronous first (1, 4), then microtask (3), then macrotask (2)
Minimum Delay: 4ms Clamping
Browsers enforce a minimum delay of ~4ms for nested setTimeout calls (after 5 nested levels). This is per the HTML spec:
// After 5 levels of nesting, minimum delay is 4ms
function nestedTimeout(level = 0) {
const start = performance.now();
setTimeout(() => {
console.log(`Level ${level}: ${(performance.now() - start).toFixed(2)}ms`);
if (level < 10) nestedTimeout(level + 1);
}, 0);
}
nestedTimeout();
// Level 0: 0.12ms
// Level 1: 0.15ms
// ...
// Level 5+: 4.00ms+ (clamped!)
Practical Patterns
Polling Pattern:
function pollStatus(url, interval, maxAttempts) {
let attempts = 0;
function check() {
attempts++;
fetch(url)
.then(res => res.json())
.then(data => {
if (data.status === "complete") {
console.log("Done!", data);
} else if (attempts < maxAttempts) {
setTimeout(check, interval);
} else {
console.log("Max attempts reached");
}
})
.catch(() => {
if (attempts < maxAttempts) {
setTimeout(check, interval);
}
});
}
check();
}
pollStatus("/api/job/123", 2000, 10);
Retry with Exponential Backoff:
async function fetchWithBackoff(url, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
const jitter = delay * Math.random() * 0.1;
console.log(`Retry ${attempt + 1} in ${delay + jitter}ms`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}
clearTimeout / clearInterval
Both functions cancel scheduled timers. Always store the timer ID and clean up:
const timeoutId = setTimeout(() => console.log("Never runs"), 5000);
clearTimeout(timeoutId);
const intervalId = setInterval(() => console.log("Never ticks"), 1000);
clearInterval(intervalId);
// Technically interchangeable (same timer pool in most engines)
// But always use the matching clear function for clarity
Common Mistakes
- Assuming
setInterval(fn, 1000)fires every 1000ms after the callback finishes — it actually schedules from the previous start time, so slow callbacks silently drift or stack up. - Treating
setTimeout(fn, 0)as "run now." It defers the callback to the next macrotask tick, which runs AFTER all pending microtasks (like Promise.then) have drained. - Forgetting to store the timer ID and call
clearTimeout/clearInterval— long-lived intervals in single-page apps are a classic memory leak and ghost-callback source.
Interview Questions
Q: What's the difference between setTimeout and setInterval?
setTimeoutexecutes a callback once after a delay.setIntervalexecutes a callback repeatedly at a fixed interval. The critical difference is thatsetIntervalcan accumulate drift because it schedules the next call at a fixed interval from the scheduling time, not the completion time. RecursivesetTimeoutis preferred for accurate intervals because each new timer starts after the previous callback finishes.
Q: What does setTimeout(fn, 0) do? Does it execute immediately?
No. It defers execution to the next event loop iteration. The callback is placed in the macrotask queue and only runs after: (1) the current synchronous code completes, and (2) all pending microtasks (Promises) resolve. It's useful for breaking up long-running synchronous code.
Q: Why would you use recursive setTimeout over setInterval?
Three reasons: (1) No drift — the delay starts after the callback completes, not from a fixed schedule. (2) You can dynamically change the delay between iterations. (3) If the callback throws an error, it stops naturally rather than continuing to fire.
Q: What's the minimum delay for setTimeout?
In practice, browsers enforce ~4ms for nested setTimeout calls (after 5 levels of nesting), per the HTML specification.
setTimeout(fn, 0)doesn't mean 0ms — it means "as soon as possible after the current execution and microtask queue."
Q: What happens when setInterval callbacks take longer than the interval?
The browser queues the next callback even if the previous one hasn't finished. This can cause callbacks to stack up and execute back-to-back. Some browsers may also skip intervals. Recursive setTimeout avoids this entirely.
Quick Reference — Cheat Sheet
setTimeout vs setInterval — QUICK MAP
setTimeout(fn, ms) -> runs fn ONCE after ms delay
setInterval(fn, ms) -> runs fn EVERY ms (can drift!)
clearTimeout(id) -> cancels setTimeout
clearInterval(id) -> cancels setInterval
setTimeout(fn, 0) -> defers to next event loop (NOT immediate)
Nested setTimeout 5+ -> 4ms minimum clamping
Prefer: recursive setTimeout over setInterval (no drift)
Drift cause:
setInterval schedules next call from PREVIOUS start time,
not from when the callback finished.
Execution order:
sync code -> microtasks (Promises) -> macrotasks (setTimeout)
Previous: Tree Shaking -> Dropping the Dead Code Next: JSON.parse / JSON.stringify -> The Edge Cases That Bite in Production
This is Lesson 14.1 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.