Skip to main content
CodeOath
← All posts

JavaScript140 min total · 15 parts

JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop

Part 14 of 15 · ~4 min

setTimeout, setInterval, and Their Gotchas

Our spinner from chapter six has been ticking away on a setInterval this whole time:

this.intervalId = setInterval(() => {
  this.dots = (this.dots + 1) % 4;
  spinnerEl.textContent = ".".repeat(this.dots);
}, 300);

Four things are wrong with it, or at least four things that are not what the code appears to promise.

First: the delay is a floor, not an appointment. setTimeout(fn, 300) means "no sooner than 300 milliseconds", and by now you know every reason it can be later — the stack has to be empty, the microtask queue has to be drained, and every macrotask already ahead of it in the queue has to run first. Put chapter seven's 400-millisecond fuzzyScore between two ticks and one tick simply arrives 400 milliseconds late. There is no mechanism by which it could arrive on time.

Second: the delay you asked for is not always the delay you get, even in theory. Two clamps are worth knowing about. A timeout nested more than about five levels deep is clamped to a minimum of 4 milliseconds, so a setTimeout(fn, 0) chain is not a free high-frequency scheduler. And in a backgrounded tab, timers are clamped to roughly once a second, with browsers free to freeze them entirely after long enough. A spinner ticking in a background tab is not ticking at 300 milliseconds, whatever the code says.

Third, and the actual setInterval trap: when a tick outlasts its interval, you lose the gap. The one thing that cannot happen is two copies of the callback running at once — there is one thread, and that settles it. What happens instead depends on the environment: the ticks either bunch up and run back-to-back with no idle time between them, or some are dropped. Both behaviours exist, neither is the cadence you asked for, and the practical symptom is a spinner that stalls and then lurches through three frames in a row.

The fix is to stop asking for a cadence and start asking for a gap — schedule the next tick only once the current one has finished:

function tick() {
  dots = (dots + 1) % 4;
  spinnerEl.textContent = ".".repeat(dots);
  spinnerTimeoutId = setTimeout(tick, 300);  // schedule the NEXT one only now
}
spinnerTimeoutId = setTimeout(tick, 300);

This guarantees at least 300 milliseconds between the end of one tick and the start of the next, and it can never bunch up. The trade is that it drifts against the wall clock: every tick's own duration is added to the total, so after a hundred ticks you are measurably behind where a perfect 300-millisecond metronome would be.

If the wall clock is what matters — a countdown, a poll that must happen roughly on the minute — compute each delay from a fixed origin so the schedule corrects itself:

const startedAt = Date.now();
let tickCount = 0;

function tick() {
  dots = (dots + 1) % 4;
  spinnerEl.textContent = ".".repeat(dots);

  tickCount += 1;
  const nextDueAt = startedAt + tickCount * 300;              // where tick N *should* land
  setTimeout(tick, Math.max(0, nextDueAt - Date.now()));      // shorten the wait to catch up
}
setTimeout(tick, 300);

A slow tick shortens the following delay instead of pushing everything back. Three patterns, three different guarantees: setInterval promises a cadence it cannot keep, recursive setTimeout promises a gap, and the self-correcting version promises a schedule.

Fourth: an uncleared timer is the memory leak from chapter four wearing a disguise. Our destroy method has been incomplete since we wrote it:

destroy() {
  inputEl.removeEventListener("input", handleInput);
  clearTimeout(spinnerTimeoutId);   // otherwise the spinner ticks forever
  clearTimeout(debounceTimeoutId);  // otherwise a search fires after teardown
  inFlight?.abort();                // otherwise a response renders into a dead widget
}

Leave the spinner timer running and three things happen at once, all of them invisible until they are not. The callback keeps executing after the widget is gone. It keeps writing to a DOM node that has been detached from the document, so the node cannot be collected. And because the callback is a closure, it holds its entire enclosing scope alive — the article index included. Navigate back and forth in a single-page app ten times and you have ten spinners ticking into ten detached nodes, each one anchoring a copy of everything the widget ever captured.

That is the same leak as chapter four. The listener you never removed and the timer you never cleared are the same bug, and both are fixed by treating teardown as part of the widget's API rather than something you remember to bolt on.

(A small portability note, since it costs one line: in browsers, timer ids are numbers from a shared pool, so clearTimeout will happily clear an interval. In Node they are objects. Clear each kind with its own function and the question never comes up.)