JAVASCRIPT / PROMISES AND ASYNC PATTERNS
setTimeout, setInterval, and timer drift
Schedule repeating JavaScript work that keeps its own deadline, explain why timers fire late, and replace setInterval with an awaited sleep loop.
What you will learn
- Read a timer delay as 'not before', never as 'exactly at'
- Compute each wait from an absolute deadline: start + n * period - Date.now()
- Replace async setInterval callbacks with an await sleep() loop so runs cannot overlap
- Keep every timer id and clear it in cleanup so stopped work stops ticking
Understanding setTimeout, setInterval, and timer drift
setTimeout(fn, d) records fn with a due time of now + d and hands back an id. When that due time passes the engine queues the callback as a task, and the task can only run once the current synchronous work has returned control to the loop. That is why d is a floor: the API promises 'not before d', it never promises 'at d'. Zero is not zero either, Node lifts 0 to 1ms, browsers clamp deeply nested timeouts to roughly 4ms, and a hidden tab throttles timers to about one per second.
Drift is what happens when you build an absolute schedule out of those relative gaps. A tick that ends with setTimeout(tick, 100) starts counting from the moment of that call, which is after the body has run, so one round costs 100ms plus the body plus whatever lateness the loop added, and that per-round error is multiplied by the number of ticks. setInterval spares you the bookkeeping but promises no more than a minimum gap between callbacks, and since it never inspects the promise an async callback returns, it keeps firing while the previous run is still awaiting.
The fix is to stop measuring gaps and start aiming at deadlines: store a start timestamp and compute the wait for tick n as start + n * period - Date.now(), clamped at zero. A slow tick then shortens the next wait instead of shoving the whole series backwards, so the error stops accumulating. Wrap that in an async loop with await sleep(...) and each round is guaranteed to finish before the next begins. When a tick is more than one period late you have to decide explicitly whether to skip the missed slots or run them back to back, because nothing in the timer API remembers the schedule you meant.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function burn(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
// Each wait starts after the previous body finished.
async function drifting(period, ticks) {
const start = Date.now();
let lastTickAt = 0;
for (let i = 1; i <= ticks; i++) {
await sleep(period);
lastTickAt = Date.now() - start;
burn(40);
}
return lastTickAt - ticks * period;
}
// Each wait aims at a fixed point on the original grid.
async function corrected(period, ticks) {
const start = Date.now();
let lastTickAt = 0;
for (let i = 1; i <= ticks; i++) {
await sleep(Math.max(0, start + i * period - Date.now()));
lastTickAt = Date.now() - start;
burn(40);
}
return lastTickAt - ticks * period;
}
async function main() {
const driftError = await drifting(100, 5);
const gridError = await corrected(100, 5);
console.log('fifth tick with relative delays is over 150ms late:', driftError > 150);
console.log('fifth tick with deadline delays is under 30ms late:', gridError < 30);
console.log('deadline version stayed on the grid:', gridError < driftError);
}
main();A timer delay is a minimum gap measured from the moment you set it, so a schedule that must stay on time has to be recomputed from an absolute deadline instead of repeated relative delays.
Worked examples
setInterval does not wait for an async callback
Shows three runs of the same interval callback alive at once because the interval ignores the promise it gets back.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let n = 0;
const id = setInterval(async () => {
const run = ++n;
console.log('start', run);
await sleep(250); // slower than the 100ms interval
console.log('end', run);
}, 100);
setTimeout(() => clearInterval(id), 320);Example explained
Line 1The async callback returns a promise as soon as it reaches await sleep(250), and setInterval discards that promise, so the next tick still arrives 100ms later.
Line 2Ticks land near 100ms, 200ms and 300ms while the first run is still waiting, which is why start 3 prints before end 1.
Line 3clearInterval(id) at 320ms only cancels future ticks, so the three runs already in flight still print their end lines.
Line 4Numbering runs with ++n is what makes the overlap visible; without it the log would look like one sequential loop.
The delay is a floor, not an appointment
Demonstrates that timers coming due during a long synchronous task run afterwards, in due-time order.
const start = Date.now();
setTimeout(() => console.log('asked for 0ms'), 0);
setTimeout(() => console.log('asked for 20ms'), 20);
setTimeout(() => console.log('asked for 200ms'), 200);
while (Date.now() - start < 120) {} // one long synchronous task
console.log('synchronous task finished around 120ms');Example explained
Line 1All three timers get their due times from the moment they are registered, before the busy loop starts.
Line 2The 0ms and 20ms timers come due mid-loop, and nothing can interrupt running code, so they only execute once the loop returns.
Line 3Their relative order is preserved because the engine keeps timers sorted by due time, not by the order the callbacks became runnable.
Line 4The 200ms timer is not yet due when the loop ends, so it fires roughly 80ms later and looks punctual, showing that lateness comes from congestion rather than from the delay value.
Cancel and re-arm with clearTimeout
Uses a timer id held in a closure to drop pending calls, keeping only the last one in each window.
function debounce(fn, wait) {
let id = null;
return (...args) => {
clearTimeout(id); // drop the pending call, if any
id = setTimeout(() => fn(...args), wait);
};
}
const save = debounce((text) => console.log('saved:', text), 50);
save('h');
save('he');
save('hel'); // only this one survives the window
setTimeout(() => save('hello'), 200);Example explained
Line 1The three save calls happen inside one synchronous task, so no timer can fire between them.
Line 2Each call cancels the id set by the previous call, which is why only the 50ms window opened by 'hel' ever elapses.
Line 3clearTimeout(null) on the first call is a harmless no-op, so no guard is needed.
Line 4The call at 200ms starts a fresh window because its predecessor already fired, so 'hello' is saved too.
Important notes
Millisecond results vary per run and per machine, which is why the examples assert bounds instead of printing raw timings; a few milliseconds of lateness are normal even on an idle thread.
The delay is stored as a 32-bit signed integer, so anything above 2147483647ms overflows and the timer fires almost immediately; long waits need a chain of shorter timeouts plus a deadline check against Date.now().
Common mistakes
Passing an async function to setInterval: with a 1s interval and a 3s request you end up with three requests in flight, responses arriving out of order, and a queue that grows for as long as the server is slow.
Treating a chained setTimeout(tick, 1000) as a clock: the delay starts counting after the body ran, so a ticker with 40ms of work per round loses roughly two seconds per minute and any counter derived from it silently falls behind wall-clock time.
Losing the timer id, for example by re-running setup and overwriting the variable, or by calling clearInterval(callbackFunction) instead of clearInterval(id): the old interval keeps firing forever and every re-entry adds another live ticker.
Try it yourself
Change, predict, then run
In a browser console, write a ticker that logs Date.now() - start for ten ticks at 200ms while burning about 120ms in a busy loop each tick. Run it once with setTimeout(tick, 200) at the end of the body and once with a deadline-based delay, and compare where tick 10 lands relative to 2000ms.
Open the JavaScript workspaceCheck your understanding
A ticker calls setTimeout(tick, 100) at the end of every tick, and each tick body takes about 30ms. Where does the 100th tick start, relative to the intended 10000ms?
- At about 10000ms, because setTimeout subtracts the body's runtime from the next delay
- At about 13000ms, because every round waits 100ms after the body finished
- At about 10030ms, because only the final body's runtime is added to the total
- Before 10000ms, because timers that are already overdue fire early to catch up
Show answer
The delay starts counting at the moment setTimeout is called, and that call happens after the body has run, so each round costs about 130ms and the 30ms error is multiplied by 100 ticks. The 'about 10030ms' option assumes the engine remembers your intended grid and compensates, but nothing in the timer API tracks it; only recomputing the delay from a stored start timestamp keeps the series aligned, and timers never fire before their due time either.