Timers and animation

Schedule work at the right rate, and cancel it when it is no longer wanted.

25of 32
≈ 10Min read
7Parts

The three schedulers

  • Visual — requestAnimationFrame. Exactly once per frame, and paused in a background tab.
  • Later, once — setTimeout.
  • Repeating non-visual work — a recursive setTimeout, not setInterval (see below).
  • Right now, after the stack — queueMicrotask.
and what each is for
const id = setTimeout(fn, 200);        // once, after at least 200ms
clearTimeout(id);

const id = setInterval(fn, 1000);      // repeatedly
clearInterval(id);

const raf = requestAnimationFrame(tick);  // before the next paint, ~60/s
cancelAnimationFrame(raf);

queueMicrotask(fn);                    // before the next macrotask
setTimeout(fn);                        // with no delay: "as soon as you can"

setInterval drifts and stacks up

why the recursive version is better
// if fn takes 300ms, the next call comes 700ms later — and if it ever
// takes longer than the interval, calls queue up behind each other
setInterval(fn, 1000);

// each wait starts after the work finishes: no overlap, no drift-induced pile-up
let id;
function loop() {
  fn();
  id = setTimeout(loop, 1000);
}
loop();
clearTimeout(id);
And with an async fn it is worse

setInterval(async () => { await slow(); }, 1000) starts a new call every second whether the last one finished or not. The recursive form cannot overlap.

requestAnimationFrame

The callback is given a high-resolution timestamp. Using the delta rather than a fixed step is what makes animation run at the same speed on a 60 Hz laptop and a 120 Hz phone.

A background tab stops calling it entirely, which is usually exactly right — and a reason not to use it as a clock.

a loop that respects the display
let raf;
let last = 0;

function tick(now) {
  const dt = now - last;      // milliseconds since the last frame
  last = now;

  move(dt);                   // use dt, so the speed is the same at 60 and 120 Hz
  raf = requestAnimationFrame(tick);
}

raf = requestAnimationFrame(tick);
// later
cancelAnimationFrame(raf);

Debounce and throttle

the two everybody writes
// debounce: run once, after things have stopped happening
const debounce = (fn, ms) => {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
};

// throttle: run at most once per interval, while things keep happening
const throttle = (fn, ms) => {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last < ms) return;
    last = now;
    fn(...args);
  };
};

// for anything visual, throttle to the frame rate instead
const rafThrottle = (fn) => {
  let queued = false;
  return (...args) => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(() => { queued = false; fn(...args); });
  };
};
UseBecause
A search box → debounceYou want the last value, not sixty of them.
Saving a draft → debounceSave when they pause typing.
A scroll readout → rafThrottleOne update per frame is the most the screen can show.
A rate-limited API → throttleA hard ceiling on calls per second.

Cancelling is not optional

A timer that outlives the thing it was updating is a leak and a bug: it keeps a reference to the whole closure, and it will happily write to an element that is no longer on the page.

a timer keeps its closure alive
let id;

function start() {
  id = setInterval(update, 1000);
}

function stop() {
  clearInterval(id);       // otherwise it runs forever, holding everything
  id = undefined;
}

// with AbortController, alongside listeners
const ac = new AbortController();
ac.signal.addEventListener("abort", () => clearInterval(id));

Delays are minimums

  • setTimeout(fn, 0) runs after the current task and all microtasks — never immediately.
  • Nested timers are clamped to about 4ms after the fifth level.
  • Background tabs throttle timers to once a second, and after a few minutes far less.
  • A blocked thread delays every timer. The delay is when it becomes eligible, not when it runs.

Measuring

performance.now, not Date.now
const t0 = performance.now();
work();
console.log(`${(performance.now() - t0).toFixed(1)}ms`);

// Date.now() is wall-clock: it can jump backwards when the system clock changes.
// performance.now() is monotonic and sub-millisecond.

Practice