DEV Community

Cover image for Your Timer Doesn't Know You Tabbed Away
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Your Timer Doesn't Know You Tabbed Away

Picture a dashboard tile polling an endpoint every two seconds to keep a number fresh. You switch to Slack to answer a message. Four minutes later you're still there, and the tile is still polling — a request every two seconds, into a tab nobody has looked at since you left it.

Multiply that by however many tabs your users leave open, and it's not a rounding error. It's server load and battery drain for work that produces zero value, because the one piece of information that would have stopped it — is anyone actually looking at this — was never checked.

The fix that feels obvious

The instinct is to pause on blur and resume on focus. It reads clean:

window.addEventListener("blur", () => clearInterval(pollId));
window.addEventListener("focus", () => { pollId = startPolling(); });
Enter fullscreen mode Exit fullscreen mode

Ship that, and eventually someone files a strange bug: a video that's supposed to keep playing in the background pauses when they click into DevTools. Or a live ticker that stops updating the instant they open a browser extension's popup. Or — the one that's easy to miss because it only happens on multi-monitor setups — a dashboard that "goes idle" while it's sitting fully visible on a second screen, because the user is typing in a different app on their main monitor.

None of those tabs are hidden. The content is right there on the glass. blur fired anyway, because blur and focus answer a narrower question than the one you actually asked.

The question you meant to ask

blur/focus tell you whether a window has keyboard focus. That's genuinely useful for things like "should this input show its focus ring" — it is not the same as "can a human currently see this content." A window can lose focus while staying fully rendered and visible; that's exactly the second-monitor case above, and it's why MDN's own guidance singles this out when explaining why the Page Visibility API exists at all, rather than just reusing focus events.

The API that answers the real question is small:

  • document.visibilityState"visible" or "hidden".
  • document.hidden — the older boolean shorthand for the same thing.
  • the visibilitychange event, fired on document whenever that state flips.

It goes "hidden" when the tab is switched away from, the window is minimized, or (on most platforms) the screen locks — the set of conditions where the content is, as far as the browser can tell, definitely not on anyone's retina. It stays "visible" in the second-monitor case above, because it correctly is. This has been standard and unprefixed in every evergreen browser for well over a decade — there's no feature-detection dance required.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Wiring it up properly

Swap the listener, not just the vocabulary:

let pollId = null;

function startPolling() {
  pollId = setInterval(fetchLatest, 2000);
}

function stopPolling() {
  clearInterval(pollId);
  pollId = null;
}

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    stopPolling();
  } else if (!pollId) {
    startPolling();
  }
});

if (!document.hidden) startPolling();
Enter fullscreen mode Exit fullscreen mode

That's the whole change. But there's a second gotcha waiting even after you've picked the right event: don't trust the tick count to tell you how long the tab was gone.

Browsers throttle timers running in tabs nobody can see — it's a deliberate battery-saving move, and it means a setInterval you started before the tab was hidden won't necessarily fire on schedule while it's backgrounded. If your "time away" logic counts ticks (ticksElapsed * intervalMs), a long background stretch will under-report itself, because some of those ticks simply never fired.

The fix is to stop counting ticks and start reading the clock. Stamp Date.now() on the way out and the way back in:

let hiddenAt = null;

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    hiddenAt = Date.now();
  } else if (hiddenAt) {
    const hiddenMs = Date.now() - hiddenAt;
    reconcile(hiddenMs); // catch up state using real elapsed time
    hiddenAt = null;
  }
});
Enter fullscreen mode Exit fullscreen mode

visibilitychange gives you the exact moments; wall-clock math gives you the exact duration between them. Neither depends on how many timer callbacks the browser decided to actually run in between.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Focus and visibility are different questions — use the one you mean

The rule of thumb: focus/blur for "does this element or window currently have keyboard input" — form fields, keyboard shortcuts, anything about input. visibilitychange for "can a human currently see this" — video and animation playback, polling and refresh intervals, analytics dwell time, autosave cadence. It's also the more reliable place to flush a last-gasp analytics beacon than beforeunload, since hidden fires on tab close too, and far more consistently across mobile browsers.

Confusing the two isn't a syntax error. It's a background video that stops for the wrong reason, and a poll that never stops for the right one.

What's ticking away in one of your background tabs right now that has no idea nobody's watching?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)