Skip to main content

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 0 a 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 setTimeout beats setInterval when the callback sometimes runs longer than the interval.

Quick example

javascript
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

  1. No stack overflow. Each setTimeout puts the next task into the macrotask queue and finishes the current one, so the stack is cleared between calls.
  2. 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.
  3. 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.
  4. Minimum delay and throttling. Even at 0 ms 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.
  5. 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.
  6. 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.
  7. 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:

    javascript
    const 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, over setInterval: 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

PropertyRecursive setTimeoutsetInterval
The next run is scheduledAfter the current callback finishesRegardless of whether the callback finished
Calls piling upImpossiblePossible when the callback is slower than the interval
Pause between runsGuaranteedNot guaranteed
Variable delayEasy, the value can be recomputed each timeNo, the interval is fixed
StoppingclearTimeout(id) for the last timerclearInterval(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 exceeded error 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 from performance.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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.