A Multi-Timer Kitchen App That Doesn't Drift When the Tab Is Backgrounded
Most browser timers use
setInterval(..., 1000)and a counter. This breaks: browsers throttle backgrounded tabs to 1 callback/minute, so a 10-minute timer comes back reading "9 minutes left" when you tab-switch. The fix is to store the start timestamp and derive remaining time fromDate.now() - startedAtat render time.
When you're cooking three things at once — pasta, sauce, roasted vegetables — you need three timers running simultaneously. Most kitchen timer apps only do one. And most web-based timers drift when you switch tabs because they're using setInterval counters instead of timestamp arithmetic.
🔗 Live demo: https://sen.ltd/portfolio/cook-timer/
📦 GitHub: https://github.com/sen-ltd/cook-timer
Features:
- Unlimited concurrent timers
- Per-timer label and duration
- Quick presets (3, 5, 7, 10, 15, 30, 60 min)
- Timestamp-based tracking (no drift under tab throttling)
- Audio alert (Web Audio API beep)
- Browser notification (Notification API)
- Wake Lock API (keeps screen on while timers run)
- localStorage persistence
- Japanese / English UI
- Zero dependencies, 50 tests
Timestamp-based timers
The naive approach:
// WRONG — drifts when tab is backgrounded
let remaining = duration;
setInterval(() => {
remaining -= 1000;
if (remaining <= 0) complete();
}, 1000);
Chrome throttles background tabs to 1 callback per minute. If you start a 10-minute timer and switch to another tab, this counter decrements 10 times in 10 minutes, not 600. The result: a "10-minute" timer that expires when you finally come back.
The correct approach:
// Store start timestamp, derive remaining at render time
timer.startedAt = Date.now();
function getRemaining(timer) {
if (timer.status !== 'running') return timer.duration - timer.pausedElapsed;
const elapsed = Date.now() - timer.startedAt + timer.pausedElapsed;
return Math.max(0, timer.duration - elapsed);
}
Now the timer is accurate regardless of how many callbacks fired. The UI can render as often or as rarely as it wants — the truth is computed from the current wall-clock time.
Pause / resume accounting
Pausing is trickier: you need to remember how much time has already elapsed when pausing, then add it back on resume:
export function pauseTimer(timer, now) {
if (timer.status !== 'running') return timer;
const elapsed = now - timer.startedAt;
return {
...timer,
status: 'paused',
pausedAt: now,
pausedElapsed: timer.pausedElapsed + elapsed,
startedAt: null,
};
}
export function startTimer(timer, now) {
if (timer.status === 'running') return timer;
return {
...timer,
status: 'running',
startedAt: now, // fresh start from *now*
pausedAt: null,
// pausedElapsed carries over from previous runs
};
}
The pausedElapsed field accumulates across multiple pause/resume cycles. On each resume, startedAt is set to "now", and getRemaining subtracts (now - startedAt) + pausedElapsed from the total duration.
Wake Lock API
To keep the screen from dimming while timers run:
let wakeLock = null;
async function acquireWakeLock() {
if ('wakeLock' in navigator) {
try { wakeLock = await navigator.wakeLock.request('screen'); } catch {}
}
}
async function releaseWakeLock() {
if (wakeLock) { await wakeLock.release(); wakeLock = null; }
}
Acquire when the first timer starts, release when the last timer stops. Graceful fallback for browsers that don't support it — the user just has to touch the screen occasionally.
Web Audio beep
No audio files, no preloading:
function beep() {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.1;
osc.connect(gain).connect(ctx.destination);
osc.start();
setTimeout(() => osc.stop(), 200);
}
Short sine wave at A5. Repeatable, audible in a noisy kitchen, no bandwidth required.
Series
This is entry #52 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/cook-timer
- 🌐 Live: https://sen.ltd/portfolio/cook-timer/
- 🏢 Company: https://sen.ltd/

Top comments (0)