The other day I was answering a question about a countdown timer that kept drifting wrong whenever the browser tab went into the background. My advice was... okayish. Good enough to be useful, not good enough that I'd want it framed on a wall. So I went back and actually thought it through, and the better answer turned out to be simpler and more correct than what I'd reached for first. Figured it was worth writing up.
The bug everyone hits first
Here's the setup. You've got a session that expires in 30 minutes, and you want to warn the user when they're about 2 minutes away — the classic "Your session is about to expire, stay signed in?" modal.
The obvious way to build this is a countdown:
let remaining = 30 * 60; // seconds
setInterval(() => {
remaining--;
render(remaining);
if (remaining === 120) warnTwoMinutes();
}, 1000);
Run that, switch to another tab for a while, come back, and your timer is wrong. Not late — wrong. It thinks there's more time left than there actually is.
The reason is that browsers throttle setInterval and setTimeout in background tabs to save resources. Chrome got especially aggressive about it starting in Chrome 88, where chained timers in a hidden tab get heavily throttled — we're talking ticks stretched out to once a minute or worse. So while you were away, your remaining-- ran a handful of times instead of hundreds, and the counter never caught up.
The trap is the remaining--. Decrementing assumes every tick happens. The moment ticks get skipped, your value is permanently off by however many you missed.
My first instinct was over-engineered
When I first ran into this, my brain jumped to: move the timer to the server, open a WebSocket, push the state down to the client. The browser can't be trusted to keep time in a background tab, so take the timing out of the browser entirely.
And look — that works. But it's a sledgehammer. You're standing up a persistent connection and a server-side scheduler to solve what is, at heart, an arithmetic mistake. For a countdown to a deadline you already know, that's a lot of moving parts to babysit.
The actual fix: don't count down, compute
The insight that makes the whole problem evaporate: store an absolute deadline, and compute the remaining time on every tick instead of decrementing.
const deadline = Date.now() + 30 * 60 * 1000;
function check() {
const remaining = deadline - Date.now();
render(remaining);
if (remaining <= 120_000) warnTwoMinutes();
}
Now throttling can't corrupt anything. A skipped tick only means check() ran later than you wanted — but whenever it does run, it recomputes from the wall clock and gets the right answer. Late, never wrong. The display in a hidden tab might lag, but it's never lying.
Pair it with one event listener and the background-tab case is genuinely handled:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") check();
});
The instant the user comes back, you recompute and the UI snaps to correct — no matter how badly the timer was throttled while they were gone.
For the 2-minute warning specifically, you can schedule it directly instead of waiting for a tick to cross the line:
setTimeout(warnTwoMinutes, deadline - 120_000 - Date.now());
In the foreground it fires roughly on time; if it's throttled in the background, the visibilitychange recompute catches it when they return. Belt and suspenders.
Where session expiry gets its own personality
Everything above is true for any countdown. But "session expiry" specifically pulls in a few extra concerns, and this is where it got interesting for me.
The deadline isn't yours to make up. The session has a real expiry living server-side. The client's job is to learn it, not invent it — from a JWT's exp claim, a /session endpoint, or a header riding along on requests you're already making. You read it; you don't compute it.
Clock skew suddenly matters a lot. The whole point is to warn before the server kills the session. If the user's laptop clock is three minutes fast, a naive Date.now() countdown warns them after they've already been booted. So anchor to the server's clock once and work off the offset:
function syncSession(serverNow, expiresAt) {
const skew = serverNow - Date.now(); // how far the client clock is off
const deadline = expiresAt - skew; // expiry expressed in client time
scheduleWarning(deadline);
}
Now the warning fires relative to what the server believes, not what the user's machine believes.
The deadline can move — on purpose. The warning usually comes with a "Stay signed in?" button, and that button extends the session:
warn at (deadline - 2min)
→ show "Stay signed in?" modal
→ button hits the refresh endpoint
→ server returns a NEW expiresAt
→ re-run syncSession() with the new deadline
So your scheduler needs a clear-and-reschedule step, so the old setTimeout gets cancelled when a refresh lands. The deadline isn't drifting — the user is pushing it forward — but your code has to handle it moving either way.
Multiple tabs will embarrass you if you forget them. User has three tabs open, clicks "stay signed in" in one, and two minutes later the other two pop the expiry warning over a session that's perfectly fine. BroadcastChannel is the clean fix:
const channel = new BroadcastChannel("session");
// after a successful refresh:
channel.postMessage({ type: "refreshed", expiresAt: newExpiry, serverNow });
// every tab listens:
channel.onmessage = (e) => {
if (e.data.type === "refreshed") syncSession(e.data.serverNow, e.data.expiresAt);
};
One tab extends, every tab reschedules. (The older fallback is a storage event on localStorage, but BroadcastChannel is purpose-built and nicer if you don't need to support ancient browsers.)
On return, re-verify instead of trusting your own math. This is the case the original throttling bug actually hides in — a tab backgrounded for a long stretch. The visibilitychange handler should do a little more than recompute, because while the tab was hidden the session might have been refreshed elsewhere, or genuinely expired:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (Date.now() >= deadline) handleExpired(); // already gone — just handle it
else revalidateWithServer(); // cheap ping; trust the server's answer
}
});
That's the honest version. The client-side timer is a UX optimization. When the user actually comes back to the tab, asking the server "am I still good?" is more truthful than trusting any local arithmetic.
So when do you need WebSockets?
Not for this. A WebSocket earns its complexity in exactly one scenario here: when the server needs to reach the client unprompted. An admin revokes a session from a dashboard, or it gets killed for a security reason, and you want every open tab to react now rather than at the next page load or visibility check. That's a genuine server-push problem, and a WebSocket (or SSE) is the right tool for it.
But "I know when this expires, warn me before it does" is not a server-push problem. It's an absolute timestamp, a skew correction, and a visibility listener. Same goes for the cookie idea I batted around early on — a cookie's browser-managed auto-expiry deletes the thing at the deadline, which is precisely too late to warn anyone, and there's no "about to expire" event to hook. Cookies are storage, not a scheduler.
The takeaway I keep relearning
The shape of the good solution:
- Server is the source of truth for when the session ends.
- Client learns the deadline and corrects for clock skew.
- Compute remaining time from an absolute deadline — never decrement.
- Schedule the warning with a stay-signed-in action that can move the deadline.
- Broadcast refreshes across tabs.
- Re-verify with the server when the tab becomes visible again.
What I like about this is that the fix wasn't a heavier hammer — it was noticing I'd modeled the problem wrong. I was treating time as something to accumulate tick by tick, when it was sitting right there as a fixed point I could just read against. Most of the timer bugs I've written come down to that same mistake.
There's more to chase here — the refresh-token dance, how this interacts with silent re-auth, what to do when the revalidation ping itself fails — but that's a rabbit hole for another day. I'll leave it on the high note.
Top comments (0)