setTimeout and setInterval explained
setTimeout in JavaScript is a minimum, not a promise. Twenty ticks of a 50ms interval took 1,213ms instead of 1,000, and the drift never comes back.
Two functions schedule work for later:
setTimeout(fn, 1000); // run fn once, after ~1 second
setInterval(fn, 1000); // run fn every ~1 second, forever
Both return an id you use to cancel:
const id = setTimeout(fn, 1000);
clearTimeout(id);
const intervalId = setInterval(fn, 1000);
clearInterval(intervalId);
The word doing the work in those comments is approximately. Neither is precise, and setInterval accumulates error in a way that matters. Everything below was measured in Node v24.14.1.
The delay is a minimum, not a promise
setTimeout(resolve, 100);
Measured, that took 110.5ms. Ten percent late, on an idle machine with nothing else running.
The delay says "put this in the queue after at least 100ms". When the time arrives, the callback waits for the current work to finish, because JavaScript runs one thing at a time. A busy main thread pushes every timer back.
That is why timers are unsuitable for anything needing real precision. For animation, requestAnimationFrame syncs to the display refresh. For audio, the Web Audio API has its own clock. For everything else, "roughly this long" is what you get.
setTimeout(fn, 0) is not immediate
setTimeout(resolve, 0);
Measured at 15.39ms, not zero.
Two things cause that. Browsers clamp nested timers to a minimum of about 4ms, and the callback still has to wait for the current task and any queued microtasks.
The ordering is worth seeing:
const order = [];
order.push("before");
setTimeout(() => order.push("timeout"), 0);
Promise.resolve().then(() => order.push("promise"));
order.push("after");
["before", "after", "promise", "timeout"]
Both synchronous lines run first. Then the promise callback. Then the timeout, last, even though it was scheduled first and asked for zero delay.
Promise callbacks are microtasks and run before the next macrotask, which is what a timer is. So setTimeout(fn, 0) means "after everything currently pending", which is exactly why it is used to defer work until after the current render or after a value has settled.
setInterval drifts
This is the finding that decides which function to use.
Twenty ticks of a 50ms interval should take 1,000ms:
| Time | |
|---|---|
| Ideal total | 1,000ms |
| Measured total | 1,213.4ms |
| Drift | 213.4ms |
More than 21% over, and the error accumulates: it does not correct itself on later ticks. Run a clock off setInterval for an hour and it will be visibly wrong.
The reason is the same lateness as setTimeout, applied repeatedly. Each tick is a few milliseconds late, and since the next one is scheduled relative to when the previous fired, every delay carries forward.
For anything that must track real time, do not count ticks. Read the clock.
const start = Date.now();
setInterval(() => {
const elapsed = Date.now() - start;
render(elapsed);
}, 1000);
The interval decides how often you update the display; Date.now() decides what the display says. Drift then affects only the smoothness of the updates, not their correctness.
setInterval versus recursive setTimeout
The two behave differently when the callback itself takes time. Measured with a 50ms interval and 30ms of work inside each tick:
| Approach | Gaps between starts |
|---|---|
setInterval |
54, 60, 62, 70, 53, 65 |
Recursive setTimeout |
52, 85, 95, 93, 91, 102 |
setInterval keeps roughly to its cadence, because the interval counts from the previous start. The work happens inside that window.
Recursive setTimeout schedules the next run only after the current one finishes, so the gap is the delay plus the work, around 80 to 100ms.
function tick() {
doWork();
setTimeout(tick, 50); // scheduled after the work
}
setTimeout(tick, 50);
Which you want depends on the job:
- A guaranteed gap between runs? Recursive
setTimeout. Polling an API this way means requests never overlap, however slow the server is. - A steady cadence regardless of work?
setInterval, accepting that slow callbacks eat into the gap.
setInterval has a genuine failure mode here. If the callback consistently takes longer than the interval, the browser cannot keep up and the callbacks queue back to back with no gap at all. Recursive setTimeout cannot do that, which is why it is the safer default for anything doing real work.
Always clear your timers
An interval runs until stopped, and one attached to a component that has gone away keeps running:
const id = setInterval(poll, 5000);
// later
clearInterval(id);
In a React component that is the effect cleanup:
useEffect(() => {
const id = setInterval(poll, 5000);
return () => clearInterval(id);
}, []);
Without the cleanup, every mount adds another interval. Navigate back and forth ten times and you are polling ten times per cycle, with the old ones holding references to stale state. It is one of the most common memory leaks in front-end code and one of the easiest to prevent.
clearTimeout and clearInterval are interchangeable in practice, since both take a timer id, but matching them to the function that created the timer is clearer.
Passing arguments and this
Extra arguments after the delay are passed to the callback:
setTimeout(greet, 1000, "Ada"); // greet("Ada")
That form is rarely needed now, since an arrow function does the same job more readably:
setTimeout(() => greet("Ada"), 1000);
The arrow also fixes the this problem. A plain function passed to setTimeout loses its binding, and inside a class method that means this is not your instance. An arrow function closes over the surrounding this, so the common bug disappears without a .bind(this).
Note the difference between passing a function and calling one:
setTimeout(greet, 1000); // passes it, runs in a second
setTimeout(greet(), 1000); // CALLS it now, schedules its return value
The second form runs immediately and then schedules whatever came back, usually undefined. It is the same mistake as onClick={handler()} in React's "too many re-renders" error.
Timers in a background tab
Browsers throttle timers in tabs that are not visible, and the throttling is aggressive. A tab in the background typically gets timers no more often than once per second, and after a few minutes the interval can stretch further or stop entirely.
That is deliberate: it saves battery and stops idle tabs burning CPU. It also means a timer-driven clock, countdown or poll is wrong when the user comes back to the tab.
Two consequences worth designing around:
Recompute from the clock on return. Listening for visibility changes lets you correct rather than accumulate:
document.addEventListener("visibilitychange", () => {
if (!document.hidden) refreshFromServerTime();
});
Do not use a timer as a source of truth. The same conclusion as the drift measurement above, for a different reason: what a timer tells you about elapsed time can be wrong by minutes, not milliseconds.
Debounce and throttle
The two patterns that account for most real setTimeout use, and they are frequently confused.
Debounce waits until the activity stops:
function debounce(fn, ms) {
let id;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), ms);
};
}
Every call cancels the pending one, so the function runs once, after the last call. This is what you want for a search box: fire the request when the user stops typing, not on every keystroke.
Throttle runs at most once per period:
function throttle(fn, ms) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= ms) {
last = now;
fn(...args);
}
};
}
The function runs immediately and then ignores calls until the period has passed. This is what you want for a scroll handler: react regularly while scrolling continues, rather than only at the end.
The distinction in one line: debounce waits for quiet, throttle limits the rate. Using debounce on a scroll handler means nothing happens until the user stops, which is usually wrong; using throttle on a search box means requests fire mid-word, which is also usually wrong.
The classic loop bug
Worth including because it is the most-asked timer question and the cause is not the timer:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3, 3, 3
All three callbacks log 3. var is function-scoped, so there is one i shared by every iteration, and by the time the timers fire the loop has finished and left it at 3.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2
let creates a new binding per iteration, so each callback closes over its own value. This is one of the clearest practical reasons let replaced var, and it is really a question about closures rather than about timers, which is covered in JavaScript closures explained.
Before let, the fix was an immediately invoked function to capture the value, which you will still see in older code and which is now unnecessary.
Three common mistakes
Trusting the delay. It is a minimum. Measured, a 100ms timer took 110.5ms on an idle machine.
Counting interval ticks to measure time. Twenty 50ms ticks took 1,213ms, not 1,000. Read the clock instead.
Forgetting clearInterval. Every uncleaned interval keeps running with stale references, and they accumulate on every mount.
requestAnimationFrame, for anything visual
If the callback moves something on screen, a timer is the wrong scheduler:
function frame() {
update();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
It runs once per display refresh, synchronised with the browser's paint, so animation is smooth rather than approximately timed. It also pauses automatically in a background tab, which throttled-but-still-running timers do less cleanly.
A 16ms setInterval is the classic wrong version of this. It approximates 60 frames per second, drifts as measured above, and can fire between paints so the work is computed and then discarded.
The callback receives a timestamp, which is what you use to make movement frame-rate independent:
function frame(now) {
const dt = now - last;
last = now;
x += speed * dt; // distance per millisecond, not per frame
requestAnimationFrame(frame);
}
Without that, animation runs at double speed on a 120Hz display, because the code assumed a frame was always about 16ms.
Quick reference
const id = setTimeout(fn, ms); // once
clearTimeout(id);
const id = setInterval(fn, ms); // repeatedly
clearInterval(id);
setTimeout(() => fn(arg), ms); // pass arguments
setTimeout(fn, 0); // defer to the next macrotask
// a guaranteed gap between runs
function tick() { work(); setTimeout(tick, ms); }
Want to watch a timer drift in real time? Start with the JavaScript track, or read how to sleep in JavaScript for the pattern built on top of setTimeout.
More from the blog
range() in Python: start, stop, step, and why it is lazy
range(1000000) uses 24 bytes. A list of a thousand numbers uses 4,028. Here is what range actually is, measured.
Read moreHow to convert a string to a number in JavaScript
Converting a string to a number in JavaScript: an empty string is 0 with Number and NaN with parseInt. What four methods do to awkward input.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.