A Stopwatch and Timer With Lap Tracking and Timestamp-Based Accuracy
Browser timers accumulated through setInterval drift when tabs are backgrounded. Timestamp-based timing doesn't. Track startedAt when the stopwatch starts, compute elapsed as
now - startedAt + accumulatedPauseson each render. The stopwatch is accurate whether you're watching it or not.
Stopwatches and timers should be boring and dependable. They usually aren't β most web-based ones drift badly when you tab away. Fixing this is a matter of not counting intervals and instead computing from timestamps.
π Live demo: https://sen.ltd/portfolio/stopwatch-timer/
π¦ GitHub: https://github.com/sen-ltd/stopwatch-timer
Features:
- Two modes: stopwatch (up) + timer (down)
- Lap tracking with best/worst highlighting
- Millisecond precision
- Keyboard: Space (start/stop), R (reset), L (lap)
- Audio beep + browser notification on timer end
- Pause/resume with correct accumulation
- Japanese / English UI
- Zero dependencies, 64 tests
The accumulation pattern
Stopwatch state:
{
status: 'idle' | 'running' | 'paused',
startedAt: number | null, // timestamp of current run segment
accumulated: number, // ms from previous runs
laps: [...],
}
The key invariant: accumulated holds the total time from all previous startβpause segments. startedAt marks the current segment (null if not running). Total elapsed is accumulated + (now - startedAt) while running, or just accumulated while paused.
export function startStopwatch(state, now) {
if (state.status === 'running') return state;
return { ...state, status: 'running', startedAt: now };
}
export function pauseStopwatch(state, now) {
if (state.status !== 'running') return state;
return {
...state,
status: 'paused',
startedAt: null,
accumulated: state.accumulated + (now - state.startedAt),
};
}
export function getElapsed(state, now) {
if (state.status === 'running') {
return state.accumulated + (now - state.startedAt);
}
return state.accumulated;
}
This pattern works regardless of how often the UI renders. Render once per second? Accurate. Render 60 times per second? Same accuracy. Render only on button clicks? Also accurate.
Lap mechanics
Each lap captures the current total elapsed and can derive the per-lap split:
export function addLap(state, now) {
if (state.status !== 'running') return state;
const cumulative = getElapsed(state, now);
const previous = state.laps[state.laps.length - 1]?.cumulative || 0;
const time = cumulative - previous;
return { ...state, laps: [...state.laps, { time, cumulative }] };
}
The first lap's time equals its cumulative (no previous). Subsequent laps subtract the previous cumulative. This gives "this lap took 12.4 seconds" alongside "total at this point: 47.1 seconds".
Best and worst laps
export function getBestLapIndex(laps) {
if (laps.length === 0) return -1;
let best = 0;
for (let i = 1; i < laps.length; i++) {
if (laps[i].time < laps[best].time) best = i;
}
return best;
}
The UI highlights the best lap green and the worst lap red. Useful for interval training or tracking consistency in repeated tasks.
Timer parseTimeString
The timer takes human-friendly input: 1:30:45, 5m, 90s, 1h30m, or bare seconds:
export function parseTimeString(str) {
str = str.trim();
// HH:MM:SS or MM:SS
if (/^\d+(:\d+){1,2}$/.test(str)) {
const parts = str.split(':').map(Number);
if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000;
if (parts.length === 3) return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000;
}
// 1h30m5s style
const match = str.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (match && match[0]) {
const h = parseInt(match[1] || '0');
const m = parseInt(match[2] || '0');
const s = parseInt(match[3] || '0');
return ((h * 60 + m) * 60 + s) * 1000;
}
// Bare number = seconds
if (/^\d+$/.test(str)) return parseInt(str) * 1000;
return null;
}
Accepts the formats users actually type. No "please enter exactly HH:MM:SS" error messages β if the user's intent is parseable, parse it.
Timer completion beep
function playBeep() {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
}
Short A5 sine wave with a 0.5-second decay. Noticeable but not annoying. Browser notification fires in parallel so you get alerted even when tabs are hidden.
Series
This is entry #90 in my 100+ public portfolio series.
- π¦ Repo: https://github.com/sen-ltd/stopwatch-timer
- π Live: https://sen.ltd/portfolio/stopwatch-timer/
- π’ Company: https://sen.ltd/

Top comments (0)