Building a Web Timer That Survives Tab Throttling and Refreshes

Published September 30, 2026 · PomoGrove

Every naive web timer ships the same bug

Write a countdown the obvious way and it works perfectly — right up until someone switches tabs:

let remaining = 25 * 60;
setInterval(() => {
  remaining -= 1;      // one second... supposedly
  renderClock(remaining);
}, 1000);

The lie is in the comment. setInterval promises "no sooner than," never "exactly." In a foreground tab you lose milliseconds to event-loop congestion; in a background tab, browsers deliberately throttle timers to save battery — Chrome clamps inactive tabs to one wake-up per second, then per minute, and with aggressive throttling a tab may barely tick at all. Every missed wake-up is a second your countdown silently never subtracts. Users of tick-counting pomodoro timers come back from a 25-minute focus block to find the timer swearing 19 minutes have passed.

For a productivity app this is not a rounding error. The entire premise of a pomodoro timer is that 25 minutes means 25 minutes.

A running session is a timestamp, not a loop

PomoGrove's rule: the wall clock is the only source of truth. Starting a session stores one number — when it ends:

state.endTime = Date.now() + minutes * 60000;
save(state);                       // persist immediately

function renderLoop() {
  const remaining = state.endTime - Date.now();
  renderClock(Math.max(0, remaining));
  if (remaining <= 0) complete();
}

The render loop still uses setInterval, but it no longer counts anything — it merely asks the clock how much time is left and draws the answer. Throttle it to once a minute, freeze it entirely: the moment the tab wakes, the very next paint is correct. Ticks became cosmetic, so nothing that delays a tick can corrupt the session.

Flowtime mode (counting up instead of down) is the same trick mirrored: store startTime, render now − startTime.

Surviving the refresh

The second classic failure is state that lives only in JavaScript variables. A refresh, a crashed tab, or an impatient "is this frozen?" F5 wipes the session. The fix is boring and absolute: every state change writes to localStorage, synchronously, at the moment it happens. Not on an autosave interval — intervals mean windows where a crash loses data.

On boot, the app merges saved state over defaults and then does the part most implementations forget — it asks what should have happened while I was gone:

const saved = load();
if (saved.endTime && saved.endTime <= Date.now()) {
  // The session finished while the page was closed.
  completeSession(saved);   // credit it, roll into the break
}

Because a session is just a timestamp, a refresh mid-session costs nothing: the page reloads, reads endTime, and the countdown resumes exactly where the universe says it should be. Close the laptop for a meeting and reopen it — same story. The clock kept running because the clock is not ours.

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

Spec-minded readers will object: Date.now() follows the system clock, which can jump — NTP corrections, timezone changes, a user fiddling with settings — while performance.now() is monotonic. Isn't monotonic safer?

For a stopwatch measuring code, yes. For a pomodoro timer, no — and the reason is instructive. performance.now() is monotonic within a page's lifetime: it dies with the tab, and on most platforms it pauses while the machine sleeps. Our sessions must survive both. A focus block that spans a laptop nap should end when the kitchen clock says it ends, not 11 minutes late because the CPU was asleep. We chose the clock users actually live by and accepted its rare jumps; a boot-time sanity check catches the pathological cases. Engineering is choosing which failures you can live with.

Offline is a trust feature, not a checkbox

The last failure mode is the network. A timer that won't load without wifi has the same broken promise as one that drifts. PomoGrove is a static page with zero dependencies — no framework, no build step — plus a service worker that caches the entire app on first visit. After that, the network is optional: the timer loads on a train, in a basement library, mid-outage. There is nothing to fetch because there is nothing dynamic; the "backend" of a running session is your own clock and your own localStorage.

The same architecture is why the whole app stays under a hundred kilobytes and scores 100s on Lighthouse — reliability and speed turn out to be the same decision made twice.

What we refuse to pretend

Honesty cuts both ways, so: a browser timer cannot ring if the tab is closed or the machine is asleep. No web app can; anyone claiming otherwise is counting on you not testing it. What we can do is guarantee the accounting — whenever you return, the session is correctly resolved: finished blocks credited, the grove grown, the streak intact. The alarm is best-effort; the record is not.

Steal this architecture

None of this is patentable cleverness — it's three decisions applied without exception: sessions are timestamps, every change persists now, the app works offline. If you're building anything with a countdown, take them. If you'd rather just use one that already works this way, the timer is free, and there's an embeddable widget version for your own site, class page, or Notion workspace — same wall-clock rule, one iframe tag.

Frequently asked questions

Why does my online timer slow down in a background tab?

Browsers throttle JavaScript timers in inactive tabs to save battery — wake-ups get clamped to once per second, then once per minute or less. Any timer that counts ticks loses every missed wake-up. Timers that compute remaining time from a stored end timestamp are immune, because each paint re-derives the truth from the system clock.

Is setInterval accurate enough for a countdown timer?

Not for keeping time — it guarantees a minimum delay, not an exact one, and background throttling stretches it arbitrarily. It is fine for triggering repaints, as long as what you paint is computed from the clock (endTime minus now) rather than accumulated by the ticks themselves.

Should a timer use Date.now() or performance.now()?

performance.now() is monotonic but resets with the page and typically pauses during system sleep, so it can't time a session that spans a refresh or a closed laptop. Date.now() tracks the wall clock users actually live by. For user-facing sessions, use Date.now() plus a boot-time sanity check for clock jumps.

Try it with a timer that adapts to you

Everything in this article works better with a timer that respects your rhythm. The PomoGrove pomodoro timer is free, has no ads, needs no signup, works offline, and never loses your session. Pair it with our focus playlists on YouTube.