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, notsetInterval(see below). - Right now, after the stack —
queueMicrotask.
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
// 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.
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
// 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); });
};
};
| Use | Because |
|---|---|
| A search box → debounce | You want the last value, not sixty of them. |
| Saving a draft → debounce | Save when they pause typing. |
| A scroll readout → rafThrottle | One update per frame is the most the screen can show. |
| A rate-limited API → throttle | A 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.
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
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.