Nested timers
A large number of "nested" setTimeout calls, that is, when the callback schedules the next timer, will not overflow the call stack, but they can flood the task queue, cause time drift, throttling and interface lag. The stack is cleared between calls; what you lose is timing accuracy.
Theory
TL;DR
- There is no stack overflow: each callback finishes before the next one runs.
- What grows instead is the macrotask queue, and the interface may start to lag.
- The tick time drifts: delays accumulate from step to step.
- Even with a delay of
0a minimum interval applies, about 4 ms, and up to 1000 ms or more in an inactive tab. - All microtasks run in full between timers, so they stretch every step of the chain.
- A recursive
setTimeoutbeatssetIntervalwhen the callback sometimes runs longer than the interval.
Quick example
function tick() {
// work...
setTimeout(tick, 0); // the next tick on the next turn of the Event Loop
}
setTimeout(tick, 0);The stack does not grow, because tick returns right after scheduling the next call. But the ticks will run under the minimum delay limit and with drift.
What exactly happens
- No stack overflow. Each
setTimeoutputs the next task into the macrotask queue and finishes the current one, so the stack is cleared between calls. - The macrotask queue grows. If each callback schedules several new timers or does heavy work, tasks start to pile up and the interface starts to lag.
- Drift, that is, accumulated delay. The firing moment shifts: the callback runs no earlier than the given delay, and even later if the stack was busy. Over a long chain the shift accumulates.
- Minimum delay and throttling. Even at
0ms browsers apply a minimum interval, usually about 4 ms, and up to 1000 ms or more in an inactive tab. Nested timers with very small delays are clamped anyway. - Microtasks between timers. After every callback all microtasks run (
Promise.then,queueMicrotask). If you create many microtasks inside a timer, they stretch each step of the chain further. - Execution order. Timers are taken from the queue by their ready time; when several become ready at once, the order can differ between engines, so it is better not to rely on a specific order.
- Memory. If the callbacks close over large objects and the chain is long or endless, memory usage can grow, because those objects stay reachable for longer.
How to do it better
-
Compensate for drift in periodic work:
javascriptconst start = performance.now(); const step = 1000; // 1 second let n = 0; function loop() { n++; // ...useful work... const nextAt = start + n * step; setTimeout(loop, Math.max(0, nextAt - performance.now())); } setTimeout(loop, step); -
For animation use
requestAnimationFrame, which is synchronised with repainting and drifts less. -
For "every N ms" without overlaps prefer a recursive
setTimeout, as above, oversetInterval: that way calls never pile up when the callback occasionally runs longer than the interval. -
Move heavy work into a Web Worker so the interface is not blocked.
Recursive setTimeout vs setInterval
| Property | Recursive setTimeout | setInterval |
|---|---|---|
| The next run is scheduled | After the current callback finishes | Regardless of whether the callback finished |
| Calls piling up | Impossible | Possible when the callback is slower than the interval |
| Pause between runs | Guaranteed | Not guaranteed |
| Variable delay | Easy, the value can be recomputed each time | No, the interval is fixed |
| Stopping | clearTimeout(id) for the last timer | clearInterval(id) |
Conclusion
Nested setTimeout calls are a safe way to "break up" synchronous recursion, but:
- there will be no exact timing;
- with a large volume of tasks you get lag and drift;
- the browser applies minimum delays and throttling.
Common mistakes
- Expecting a stack overflow. This is not recursion in the usual sense: the stack is empty between ticks, so no
Maximum call stack size exceedederror appears, and the problem shows up later as lag. - Scheduling several timers in each callback. The chain branches and the number of queued tasks grows explosively.
- Counting on a delay of
0. Nested timers are clamped to roughly 4 ms, so a loop of 1000 steps takes at least a few seconds. - Computing elapsed time as
step * tick count. Because of drift the real time is larger; read it fromperformance.now()instead. - Never stopping the chain. Without a flag or a stored id the timer runs forever, even after the screen that started it has been closed.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.