DEV Community

SEN LLC
SEN LLC

Posted on

A Multi-Timer Kitchen App That Doesn't Drift When the Tab Is Backgrounded

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 from Date.now() - startedAt at 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

Screenshot

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)