Why Your Countdown Timer Lies After the Tab Goes Idle
June 20, 2026 8 min read

Why Your Countdown Timer Lies After the Tab Goes Idle

A countdown showed garbage after a tab sat in the background — but only when nobody was watching. The cause was background-tab timer throttling; the real bug was accumulating time instead of deriving it.

A heisenbug, a browser rule I’d never read, and one line of my own code that was actually wrong.

TL;DR — A countdown showed garbage after a tab sat backgrounded. The trigger was background-tab timer throttling; the real bug was that I accumulated elapsed time (remaining -= 1000) instead of deriving it from an absolute deadline. The fix: recompute from a source of truth, paint with requestAnimationFrame, and separate repaint from refetch on visibilitychange. (A sequel — “Why Date.now() Is Still Wrong” — covers the second way a timer lies: the clock itself.)


”It’s counting down to negative numbers”

The bug report had no steps, which is the kind I’ve learned to fear:

QA: The round countdown is showing weird numbers. Saw it hit -4:17, then it just froze. Me: Can you send a video? QA: It’s fine now. I closed the tab and reopened it.

I ran the app. Watched the timer through a full round. Then another. Refreshed, tried mobile, throttled the network. Flawless. So I did the thing I’m not proud of and closed it “cannot reproduce.”

Three testers reopened it within the hour. They were right and I was wrong. Every report had the same shape: leave the app open, go do other work, come back — the timer’s garbage. Close and reopen — perfect again. Nobody could hand me a repro, because the repro was the part where they walked away.

The bug only existed when nobody was watching it.


Three dead ends

I found three wrong answers before the right one:

  • Clock skew? Negative numbers scream “someone’s system clock is off.” I logged Date.now() on every tick — timestamps were fine whenever the tab was focused. Dead end (but keep this one in mind).
  • A state race? Maybe two rounds were stomping each other’s endTime. I logged every state update. Single writer, sane values. Dead end.
  • React weirdness? I ripped the timer into a bare sandbox with none of the app around it. It still drifted — the useful failure: the bug wasn’t in my app, it was in my assumption about the timer.

Then I looked at the tick logs from a session I’d left backgrounded over lunch:

12:04:01  tick
12:04:02  tick
12:04:03  tick
        ← switched tabs here
12:09:03  tick        ← five-minute gap
12:10:03  tick        ← now one tick per MINUTE
12:11:03  tick

While the tab was hidden, my setInterval(…, 1000) had quietly gone from once per second to once per minute.


The rule I didn’t know I was relying on

The naive countdown, close to what I’d shipped:

let remaining = endTime - Date.now();

setInterval(() => {
  remaining -= 1000;   // subtract one second per tick
  render(remaining);
}, 1000);

It’s only fine while that callback fires once per second — the exact guarantee the browser revokes when your tab loses focus. Browsers throttle background timers to save battery; it’s documented and intentional ( MDN, Chrome):

  • Hidden tab: timers clamp to at most once per second.
  • After ~5 min hidden: “intensive throttling” — at most once per minute.

Here’s the whole failure on one picture:

FOCUSED   tick·tick·tick·tick·tick               1 / second
   │  (switch away)
HIDDEN    tick······5 min······tick···1 min···tick    throttled → 1 / minute
   │  (come back)
counter   05:00 → 00:00 → keeps subtracting → sails to  −4:17
reality   05:00 → 00:00 → round ended long ago

My loop subtracted one second per throttled tick, so it fell behind by 59 seconds for every minute I was away. Twenty minutes backgrounded ≈ nineteen minutes of lost time — straight past endTime into the negatives QA kept seeing.

The timer wasn’t broken. It executed my instructions perfectly. I’d just given it instructions that were only true in a focused tab.


The real bug: accumulate vs derive

Throttling didn’t cause the wrong number — it exposed a flaw baked in from the first line: I was accumulating time instead of deriving it.

The moment you write remaining -= 1000, correctness depends on every tick landing on schedule. Miss one — to throttling, a GC pause, a sleeping laptop — and the error is permanent, and it compounds.

ACCUMULATE  ❌                         DERIVE  ✅
state:  remaining                      state:  endTime  (never changes)
tick:   remaining -= 1000              render: remaining = endTime − now()

a missed tick is lost forever          a missed tick costs one frame

Derive from the source of truth and the whole class of bug disappears:

function render() {
  const remaining = Math.max(0, endTime - Date.now());  // recompute, every time
  paint(remaining);
}

Now it doesn’t matter when or how often render runs. Once a second, once a minute, once when I’m back from lunch — every call computes the right answer from an absolute deadline. Throttling can only make it repaint less often; it can no longer make it wrong.

The whole fix is one character of intent:

- remaining -= 1000                 // trusts every tick to fire on time
+ remaining = endTime - Date.now()  // trusts nothing but the clock

Paint with requestAnimationFrame

Here’s the part worth being precise about: correctness no longer depends on the scheduler. Now that we derive, even setInterval(…, 1000) would be correct — it would just repaint on a clumsier cadence. So what drives the repaints is now purely about efficiency and smoothness, and there requestAnimationFrame wins: browsers suspend or heavily throttle rAF in a hidden tab, so while nobody’s looking it does essentially zero work. When the tab is visible again the loop resumes, and because we derive, the very first frame paints the correct time — no catch-up.

let lastShown = -1;

function loop() {
  const remaining = Math.max(0, endTime - Date.now());
  const secs = Math.ceil(remaining / 1000);
  if (secs !== lastShown) {   // only touch the DOM when the visible value changes
    lastShown = secs;
    paint(remaining);
  }
  if (remaining > 0) requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

rAF fires ~60×/second; a countdown changes once a second. Gate the DOM write on the visible value changing, or you’ll re-render sixty times to show the same number.


The last mile: what happened while I was gone?

Deriving fixes the number. It can’t answer a second question: what changed while the tab was hidden?

Each countdown belongs to a round, and rounds settle and roll over on their own. Background the tab for twenty minutes and you come back to a round that ended long ago. Recomputing endTime - Date.now() gives a perfectly correct countdown to a deadline that no longer matters.

So “tab became visible” does two distinct things:

  1. Recompute the display — cheap, instant, no network.
  2. Refetch the authoritative state — did the round roll over? — which costs a request.
document.addEventListener("visibilitychange", () => {
  if (document.hidden) return;
  render();        // (1) correct number on the first frame back
  maybeResync();   // (2) refetch — but only when it actually mattered
});

One caveat: don’t refetch on every visibility change — someone alt-tabbing constantly would fire a burst of requests. Gate the expensive half on how long you were away, not how many times you switched: recompute always (it’s free), but only refetch after you’ve been gone long enough to matter. Not every visibilitychange deserves a round trip.


What I took away

The fix was small; the lesson is the shape of why I couldn’t see it:

  • A “cannot reproduce” bug is a difference in conditions, not code. The condition was time in the background — and by staring at the timer to debug it, I’d engineered the repro away. The testers weren’t wrong.
  • The browser can revoke your assumptions at runtime. “This callback runs every second” was true in my tab and false in theirs.
  • Derive, don’t accumulate. Store the source of truth; compute the derived value on demand. A missed beat should cost you a frame, not the truth.

…but the clock can lie too

Deriving trusts one thing completely: Date.now(). And Date.now() is device time — a number the user controls, and one that can jump mid-session. A week later, another impossible-timer video landed on my desk — and this one hadn’t been backgrounded at all.

That’s part two: Why Date.now() Is Still Wrong — server-time sync, monotonic clocks, and the tiny library I pulled all of this into.


Further reading

Explore more articles