Dev.to WebDev 🛠 Dev 👁 0 📖 2 min read

A browser stopwatch that stays right when the tab sleeps

A stopwatch looks like the easiest tool to build: add 10 ms every 10 ms and print the total. That version is wrong within minutes in a background tab. Here is how the stopwatch on my site keeps time instead. Do

A stopwatch looks like the easiest tool to build: add 10 ms every 10 ms and print the total. That version is wrong within minutes in a background tab. Here is how the stopwatch on my site keeps time instead.

Don't count ticks, subtract timestamps

Browsers slow down timers in hidden tabs, and a phone with the screen off may not run them at all. Any stopwatch that adds up ticks loses exactly that time.

So the state is three numbers: whether it is running, the moment it was started, and the time banked from earlier runs.

const elapsed = (s, now) =>
  s.acc + (s.running && s.startedAt !== null ? Math.max(0, now - s.startedAt) : 0);

Pause moves the current run into acc and clears startedAt. Start sets startedAt to now. The display timer only decides how often the number is redrawn; it never changes the number. If the tab sleeps for a minute, the next redraw is simply a minute further on.

Why Date.now() and not performance.now()

performance.now() is the better clock for measuring, because it doesn't jump if someone changes the computer's clock. But it starts again from zero on every page load, and I wanted the stopwatch to survive a reload. Date.now() can be saved and compared after a reload, so I chose it and accepted that changing the system clock mid-run will show up in the total.

Surviving a reload

The state is saved to the browser's storage after every start, pause and lap. On load it goes through a strict check: running must be a boolean, acc and every lap a finite number of zero or more, and a running stopwatch must have a start time. Anything else gives a fresh stopwatch rather than a strange total. In testing I fed it {"running":true,"acc":5}, a running state with no start time, and it came back as a clean zero.

Laps are totals, splits are computed

A lap stores only the total time at that moment. The length of each lap is worked out when the table is drawn:

const rows = laps.map((t, i) => ({ n: i + 1, split: t - (i ? laps[i - 1] : 0), total: t }));

That keeps the saved data small. With laps at 1:02.34, 2:05.10 and 3:10.87 in total, the splits are 1:02.34, 1:02.76 and 1:05.77, and once there are more than two laps the table marks the fastest and the slowest.

The display truncates to hundredths rather than rounding, so 65,329 ms shows as 1:05.32. Rounding would sometimes show a time the stopwatch hadn't reached yet.

Redraw on the second

For a stopwatch, redrawing every few milliseconds is fine. For a clock that shows whole seconds it isn't, because a 1-second interval started at a random moment changes the digits up to a second late. The world clock on the same site waits for the next whole second before starting its interval:

setTimeout(() => { tick(); setInterval(tick, 1000); }, 1000 - (Date.now() % 1000));

What I'd tell myself at the start

  1. Store moments, not counts.
  2. Decide what has to survive a reload before choosing the clock.
  3. Validate saved state as if a stranger wrote it.

What clock source do you use for timers that need to survive a page reload?

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.