setTimeout(fn, 100) doesn't mean "run fn in 100 ms". It means "run fn once at least 100 ms have passed". The delay is a minimum, not a promise, and the same goes for setInterval. A timer is often a millisecond or two late and sometimes far more, because when its time comes, its callback can only join the line of things waiting to run.
JavaScript in a page runs on one thread, the main thread, which also handles clicks, layout, and drawing, one task at a time. A timer's callback can't start until the code that's running has finished. So a long task, code that keeps the thread busy for a while, holds every timer back:
const start = performance.now();
setTimeout(() => {
console.log(`ran after ${Math.round(performance.now() - start)} ms`);
}, 100);
// Keeps the thread busy for 300 ms, as a big loop over data might
while (performance.now() - start < 300) {}
// logs "ran after 300 ms" (or a few milliseconds more), not 100
| Cause | What happens to the timer |
|---|---|
| The main thread is busy | It waits until the running code finishes, as above. |
| Timers set inside timers | When each timer is set from the previous one's callback, browsers raise any delay under 4 ms to 4 ms once the chain is about five deep. A chain of setTimeout(fn, 0) ends up running about every 4 ms. |
| The tab is in the background | Browsers usually run timers in hidden tabs at most about once a second. Chrome can stretch some to once a minute after a page has been hidden for five minutes. |
| The computer is asleep | Nothing runs while it sleeps, so a timer that comes due then can't fire until it wakes. |
A few milliseconds late is usually harmless. The trouble starts when a repeating timer is built as a chain of setTimeout calls, each set from inside the previous callback to wait the full interval again. Each wait starts only when the previous callback ran, late, so every delay is added on to the next, and the schedule falls further behind with every tick. That's called drift. Here are 100 ms timers in such a chain, where each one fires 7 ms late:
| Tick | Timer set at | Fires at | Due at | Behind by |
|---|---|---|---|---|
| 1 | 0 ms | 107 ms | 100 ms | 7 ms |
| 2 | 107 ms | 214 ms | 200 ms | 14 ms |
| 3 | 214 ms | 321 ms | 300 ms | 21 ms |
| 10 | 963 ms | 1070 ms | 1000 ms | 70 ms |
| 600 | 64093 ms | 64200 ms | 60000 ms | 4200 ms |
Each timer was only 7 ms late, but the lateness adds up: after 600 ticks, which should take a minute, the chain is 4.2 seconds behind. Counting ticks to measure time goes wrong the same way. Code that adds 100 ms for each tick says 1000 ms have passed after 10 ticks, when 1070 have. In a background tab, where the timer fires about once a second, it would add 100 ms for every second that passes. Countdowns, stopwatches, on-screen clocks, and anything else that keeps time with timers run into this.
Timers fire late: a busy page or a background tab can hold a callback back by milliseconds, or by seconds. When each run of a setTimeout chain waits interval ms from when it ran, every delay adds up, and the schedule falls further behind with every tick.
Write accurateInterval(fn, interval), which keeps to a fixed schedule instead. Tick 1 is due interval ms after the call, tick 2 at 2 * interval ms, and tick n at n * interval ms, always counted from the call. Each tick runs as soon as the timers allow once it's due, never before, and a late tick doesn't move the ones after it.
Each tick calls fn(n), where n is the tick's number.
If a timer fires so late that several ticks are due, fn runs once, for the latest of them, and the earlier ones are skipped: it never runs several times in a row to catch up.
It returns a function, stop(), that stops the ticks for good and leaves no timer running. stop() also works when fn calls it.
Use setTimeout, not setInterval: an interval's delay is fixed, so it can't correct itself.
const stop = accurateInterval((n) => console.log("tick", n), 1000);
// ticks are due at 1000, 2000, 3000 ms, and so on, after the call
// at 1004 ms (its timer fired 4 ms late): logs "tick 1"
// at 2001 ms: logs "tick 2", as it's due at 2000 ms, not 1004 + 1000
// the page freezes from 2500 to 5300 ms, then:
// at 5300 ms: logs "tick 5" (ticks 3 and 4 never run)
// at 6000 ms: logs "tick 6"
stop(); // no more ticks
const clock = useFakeTimers();
const fn = spy();
accurateInterval(fn, 100);
clock.tick(99);
expect(fn).toHaveBeenCalledTimes(0);
clock.tick(1);
expect(fn).toHaveBeenLastCalledWith(1);
clock.tick(200);
expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
const clock = useFakeTimers();
const start = clock.now();
const fakeSetTimeout = globalThis.setTimeout;
// Every timer fires 7 ms late, as on a busy page.
globalThis.setTimeout = (callback, ms, ...args) => fakeSetTimeout(callback, Math.max(0, ms || 0) + 7, ...args);
const runs = [];
try {
accurateInterval((n) => runs.push({ n, at: clock.now() - start }), 100);
clock.tick(1010);
} finally {
globalThis.setTimeout = fakeSetTimeout;
}
// Tick n is due at n * 100 ms, so it should run 0 to 7 ms after that: never earlier, and never more than one timer's lateness behind.
const offSchedule = runs.filter(({ n, at }) => at < n * 100 || at > n * 100 + 7).map(({ n, at }) => "tick " + n + " at " + at + " ms");
expect(offSchedule).toEqual([]);
expect(runs.map(({ n }) => n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const clock = useFakeTimers();
const start = clock.now();
const fakeSetTimeout = globalThis.setTimeout;
// The page is busy from 150 to 230 ms: timers due in between run at 230 ms.
globalThis.setTimeout = (callback, ms, ...args) => {
const now = clock.now() - start;
const due = now + Math.max(0, ms);
return fakeSetTimeout(callback, due >= 150 && due < 230 ? 230 - now : ms, ...args);
};
const runs = [];
try {
accurateInterval((n) => runs.push(n + " at " + (clock.now() - start)), 100);
clock.tick(450);
} finally {
globalThis.setTimeout = fakeSetTimeout;
}
expect(runs).toEqual(["1 at 100", "2 at 230", "3 at 300", "4 at 400"]);
const clock = useFakeTimers();
const start = clock.now();
const fakeSetTimeout = globalThis.setTimeout;
// The page freezes from 150 to 550 ms: timers due in between run at 550 ms.
globalThis.setTimeout = (callback, ms, ...args) => {
const now = clock.now() - start;
const due = now + Math.max(0, ms);
return fakeSetTimeout(callback, due >= 150 && due < 550 ? 550 - now : ms, ...args);
};
const runs = [];
try {
accurateInterval((n) => runs.push(n + " at " + (clock.now() - start)), 100);
clock.tick(750);
} finally {
globalThis.setTimeout = fakeSetTimeout;
}
expect(runs).toEqual(["1 at 100", "5 at 550", "6 at 600", "7 at 700"]);
const clock = useFakeTimers();
const fn = spy();
const stop = accurateInterval(fn, 100);
clock.tick(250);
stop();
expect(clock.pending()).toBe(0);
clock.tick(1000);
expect(fn).toHaveBeenCalledTimes(2);
const clock = useFakeTimers();
let stop;
const fn = spy((n) => {
if (n === 3) stop();
});
stop = accurateInterval(fn, 100);
clock.tick(1000);
expect(fn).toHaveBeenCalledTimes(3);
expect(clock.pending()).toBe(0);
const clock = useFakeTimers();
const fakeSetInterval = globalThis.setInterval;
globalThis.setInterval = () => {
throw new Error("accurateInterval must not use setInterval");
};
const fn = spy();
try {
const stop = accurateInterval(fn, 100);
clock.tick(350);
stop();
} finally {
globalThis.setInterval = fakeSetInterval;
}
expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Interval that corrects its own drift. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.