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.
Write createStopwatch(). It returns a stopwatch, with the methods below, that starts out stopped, at 0 ms.
start() starts counting, or carries on from where it stopped. stop() stops counting and keeps the time counted so far. Calling start() while it's running, or stop() while it's stopped, does nothing.
elapsed() returns the total time counted, in milliseconds, exact to the millisecond. Time while it's stopped doesn't count. It must stay exact even when timers run late or rarely, as they do in a background tab.
lap() ends the current lap and starts the next one. It returns the lap's length: the time counted since the previous lap, or, for the first lap, since 0 ms.
laps() returns the lengths of all the laps so far, oldest first, in a new array each time.
reset() stops the stopwatch, sets it back to 0 ms, and forgets the laps.
const watch = createStopwatch();
watch.start();
// 1.5 s later:
watch.lap(); // 1500
// 2 s later:
watch.stop();
watch.elapsed(); // 3500
// 10 s later, still stopped:
watch.start();
// 0.25 s later:
watch.lap(); // 2250: 2000 before stop() and 250 after start()
watch.laps(); // [1500, 2250]
watch.reset();
watch.elapsed(); // 0
watch.laps(); // []
const clock = useFakeTimers();
const watch = createStopwatch();
clock.tick(500);
expect(watch.elapsed()).toBe(0);
expect(watch.laps()).toEqual([]);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(1234);
expect(watch.elapsed()).toBe(1234);
clock.tick(1);
expect(watch.elapsed()).toBe(1235);
const clock = useFakeTimers();
const fakeSetTimeout = globalThis.setTimeout;
const fakeSetInterval = globalThis.setInterval;
// As in a background tab: no timer runs more often than once a second.
globalThis.setTimeout = (callback, ms, ...args) => fakeSetTimeout(callback, Math.max(1000, ms || 0), ...args);
globalThis.setInterval = (callback, ms, ...args) => fakeSetInterval(callback, Math.max(1000, ms || 0), ...args);
let elapsed;
try {
const watch = createStopwatch();
watch.start();
clock.tick(2500);
elapsed = watch.elapsed();
} finally {
globalThis.setTimeout = fakeSetTimeout;
globalThis.setInterval = fakeSetInterval;
}
expect(elapsed).toBe(2500);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(1000);
watch.stop();
clock.tick(5000);
expect(watch.elapsed()).toBe(1000);
watch.start();
clock.tick(500);
expect(watch.elapsed()).toBe(1500);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.stop();
watch.start();
clock.tick(300);
watch.start();
clock.tick(200);
expect(watch.elapsed()).toBe(500);
watch.stop();
clock.tick(100);
watch.stop();
expect(watch.elapsed()).toBe(500);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(1500);
expect(watch.lap()).toBe(1500);
clock.tick(2000);
expect(watch.lap()).toBe(2000);
clock.tick(250);
expect(watch.lap()).toBe(250);
expect(watch.elapsed()).toBe(3750);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(1500);
watch.lap();
clock.tick(2000);
watch.stop();
clock.tick(10000);
watch.start();
clock.tick(250);
expect(watch.lap()).toBe(2250);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(100);
watch.lap();
clock.tick(200);
watch.lap();
const laps = watch.laps();
expect(laps).toEqual([100, 200]);
laps.push(999);
expect(watch.laps()).toEqual([100, 200]);
const clock = useFakeTimers();
const watch = createStopwatch();
watch.start();
clock.tick(1000);
watch.lap();
clock.tick(500);
watch.reset();
expect(watch.elapsed()).toBe(0);
expect(watch.laps()).toEqual([]);
clock.tick(1000);
expect(watch.elapsed()).toBe(0);
watch.start();
clock.tick(300);
expect(watch.lap()).toBe(300);
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 Stopwatch with laps. 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.