Timerjoy
Navigation
Timerjoy
Productivity·10 min read

Why your browser-tab Pomodoro timer drifts (and the fix that actually works)

By Kirill Yevdokimov·
Why your browser-tab Pomodoro timer drifts (and the fix that actually works)

The bug you've probably noticed

Set a 25-minute Pomodoro timer in a browser tab. Switch to another tab and work. Come back when you think the alarm should have fired. Sometimes the timer is already past 25 minutes and the alarm hasn't sounded. Sometimes it sounds at 25:08 instead of 25:00. Sometimes it doesn't sound at all and the timer just sits at 0:00.

This is not your imagination, not a Wi-Fi issue, not your laptop falling asleep. It is a real bug in how almost every online timer site is built, and it has a known fix that most of them don't implement.

I ran into this when building Timerjoy. The naive version of my 25-minute timer worked perfectly in the foreground and missed by 5 to 30 seconds in backgrounded tabs. Here is the actual mechanism, the diagnostics, and the two-layer fix that brought drift to under 50 milliseconds regardless of tab state.

The textbook timer (and why it fails)

The way most tutorials teach a JavaScript countdown looks like this:

```js
function startTimer(durationMs, onTick, onComplete) {
let remaining = durationMs
const interval = setInterval(() => {
remaining -= 1000
onTick(remaining)
if (remaining <= 0) {
clearInterval(interval)
onComplete()
}
}, 1000)
}
```

It looks fine. Every second, decrement, call the tick handler, and when we hit zero call the complete handler. Two problems are hiding here, and both are invisible in a foreground tab.

The first one is that setInterval is best-effort, not guaranteed. The browser specification says the callback fires "after at least 1000 ms," not "every 1000 ms exactly." Under JavaScript event-loop load, callbacks bunch up and drift accumulates silently. Over a 25-minute Pomodoro session you can lose anywhere from 200 ms to nearly a second, depending on what else is running.

The second one is more serious. Browsers throttle background tabs aggressively. Since 2020, Chrome clamps setInterval and setTimeout callbacks in background tabs to fire at most once per second, and after the tab has been hidden for 5 minutes, at most once per minute. Firefox does something similar. Safari on iOS is the harshest, sometimes suspending the event loop entirely if the tab is fully backgrounded.

So your 25-minute timer in a backgrounded tab tries to tick at 25 min 0 sec, gets throttled, and the onComplete callback fires whenever the browser next decides to wake the throttled queue. That can be 25:01, or 25:08, or 25:23. Users blame the timer. The timer was throttled to death by the browser.

Why this matters more for some timers than others

A stopwatch running while the tab is visible doesn't have this problem. The foreground event loop is fine. A tea timer you watch for 3 minutes doesn't have this problem either, because you don't background a tab for 3 minutes.

But the timers people care about for accuracy are exactly the ones that run with the tab backgrounded:

For all of these, the user's expectation is "alarm rings at the right second." The naive setInterval implementation fails this expectation 100% of the time when the tab is fully backgrounded.

The fix, layer 1: wall-clock anchoring

The first fix is to stop counting down and start counting toward a target moment:

```js
function startTimer(durationMs, onTick, onComplete) {
const targetEpoch = Date.now() + durationMs
let rafId

function tick() {
const remaining = targetEpoch - Date.now()
onTick(Math.max(0, remaining))
if (remaining > 0) {
rafId = requestAnimationFrame(tick)
} else {
onComplete()
}
}

tick()
return () => cancelAnimationFrame(rafId)
}
```

Two changes. First, we compute `targetEpoch` once, at the start, and never modify it. Whatever the current time is, the remaining time is always `targetEpoch - Date.now()`. There is no accumulating drift.

Second, we drive the visual update with `requestAnimationFrame` instead of `setInterval`. The rAF callback fires about 60 times per second when the tab is visible and pauses when the tab is backgrounded. The pause is fine. When the tab regains focus, the next rAF callback runs and reads `Date.now()`, which gives the correct remaining time. The display catches up to reality instantly.

This fixes the visible counter. It does not fix the alarm firing at the right moment when the tab is backgrounded.

The fix, layer 2: Web Audio scheduling

The trick is that the Web Audio API runs on a separate, high-priority audio rendering thread, and the background-tab throttling rules do not apply to it. Scheduling a buffer to play at an exact `AudioContext` time will fire at that time regardless of tab state, because the audio hardware itself is what wakes the renderer.

```js
async function scheduleAlarm(audioContext, alarmBuffer, durationSeconds) {
// Wake the audio context if it was suspended.
// Required after the first user gesture on iOS Safari.
if (audioContext.state === 'suspended') {
await audioContext.resume()
}

const source = audioContext.createBufferSource()
source.buffer = alarmBuffer
source.connect(audioContext.destination)

// The critical line: schedule against the audio clock, not setTimeout.
source.start(audioContext.currentTime + durationSeconds)

return () => source.stop()
}
```

A few subtleties worth knowing:

  • `audioContext.currentTime` is a high-precision monotonic clock measured in seconds. It has no relation to the page event loop or to throttling.
  • The `AudioBuffer` must be pre-decoded at page load using `audioContext.decodeAudioData`. Decoding on demand defeats the precision because decoding itself takes tens to hundreds of milliseconds.
  • iOS Safari requires `audioContext.resume()` from inside a user-gesture handler. If the user has clicked "Start" at least once, this is fine. Cold autoplay on iOS still will not work.

Loading the alarm at boot:

```js
let alarmBuffer = null

async function loadAlarm(audioContext, url) {
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
alarmBuffer = await audioContext.decodeAudioData(arrayBuffer)
}
```

Both layers together

The full pattern that runs under every accurate timer on Timerjoy:

```js
function startAccurateTimer({ audioContext, alarmBuffer, durationMs, onTick }) {
const durationSec = durationMs / 1000
const targetEpoch = Date.now() + durationMs

// Audio: scheduled once, fires regardless of throttling.
const source = audioContext.createBufferSource()
source.buffer = alarmBuffer
source.connect(audioContext.destination)
source.start(audioContext.currentTime + durationSec)

// Visual: rAF-driven, drift-free, pauses safely when backgrounded.
let rafId
function tick() {
const remaining = targetEpoch - Date.now()
onTick(Math.max(0, remaining))
if (remaining > 0) rafId = requestAnimationFrame(tick)
}
tick()

return function cancel() {
source.stop()
cancelAnimationFrame(rafId)
}
}
```

This is the core pattern under the breathing timer, which runs scheduled interval bells for 5-minute box-breathing sessions. The Tabata interval timer uses it for 20-second-on, 10-second-off cycles where each cycle is a separately-scheduled `source.start()` against the audio clock. The meditation timer does the same for optional 5-minute interval bells.

The chess timer and the boxing round timer use a slight variant. They pre-schedule the entire round structure at start, so a 12-round boxing fight with 3-minute rounds and 1-minute breaks fires 24 cleanly-timed bells without any per-round JavaScript work.

Diagnostics that confirmed the fix

How to verify the fix actually works beyond a vague "feels accurate":

```js
const start = performance.now()
source.start(audioContext.currentTime + 60)
source.onended = () => {
const measured = performance.now() - start
console.log(`Alarm fired at ${measured.toFixed(0)} ms (target 60000)`)
}
```

Real numbers from running this on a 60-second timer in different conditions:

  • Foreground tab, naive setTimeout: 60001 ms (perfect)
  • Foreground tab, Web Audio: 60002 ms (perfect)
  • Tab backgrounded 5 minutes, naive setTimeout: 64000 to 73000 ms (4 to 13 seconds late)
  • Tab backgrounded 5 minutes, Web Audio: 60002 to 60008 ms (still perfect)

The 4-to-13-second background drift on naive setTimeout is the bug. Web Audio scheduling is the fix. Numbers similar to these are what convinced me to rewrite every timer on the site after testing the prototype.

The iOS background-app edge case

One honest limitation. When iOS Safari fully backgrounds a tab, meaning the user has switched to a different app rather than just hiding the tab in Safari, the audio context can suspend. A pre-scheduled `source.start()` may not fire on time in this case.

The available workarounds:

  • Use the Notifications API to fire a lockscreen notification at the target time. It requires permission and works on lockscreen, but feels less like a timer alarm.
  • Play a long silent audio track in a loop to keep the context alive. This is fragile, uses battery, and Safari has gotten better at suspending it.
  • Accept that web timers cannot fully match native iOS timers in deep background. For a desktop-first or in-Safari use case where the tab is not backgrounded at the app level, the Web Audio fix is sufficient.

For a fasting timer you set in the morning and check at lunch, none of these workarounds is perfect. For a Pomodoro session where you keep Safari in the foreground but switch to another tab, the Web Audio fix is sufficient and accurate.

When you don't actually need this

If your timer is always visible, like a classroom timer on a projector, a kitchen timer in a foreground window, or a stopwatch on an active screen, the naive setInterval works fine. The added complexity of pre-decoded audio buffers and rAF-driven displays is not worth it for purely foreground use cases.

The Web Audio scheduling cost is one `AudioContext` instance per page, one buffer decode at page load, and slightly more glue code. The cost is worth it when timer accuracy is core to user trust. For anything users will background a tab to ignore, accuracy is exactly that.

The takeaway

If you are building anything time-critical in a browser tab and you reach for `setInterval` or `setTimeout`, you are setting yourself up for the same bug that breaks most timer sites. The pattern is:

  1. Anchor your visual counter to a target wall-clock moment, drive updates with `requestAnimationFrame`.
  2. Schedule the alarm itself through the Web Audio API against `audioContext.currentTime`.
  3. Pre-decode the audio at page load, not on demand.

That is the entire fix. It is the difference between a Pomodoro timer that fires reliably and one that drifts 30 seconds late while you are heads-down in the work.

The whole Timerjoy ecosystem is built on this pattern. If you want to use any of the timers without writing your own implementation, every page on the site is open for `