WebSocket reconnect with the Page Visibility API #
Users leave your app in a background tab for twenty minutes, switch back, and see data that is twenty minutes old. Sometimes the socket is dead and the reconnect banner appears only after a long pause; sometimes the socket is alive but the app missed a burst of updates while its timers were throttled. Meanwhile, your server logs show hidden tabs reconnecting in loops at 3 a.m., because a client-side heartbeat timer fired late, concluded the server was gone, and tore down a perfectly healthy connection. Background tabs are a distinct operating mode for a real-time client, and the Page Visibility API is how you tell the client which mode it is in.
Root cause #
Browsers aggressively limit work in hidden tabs. Chromium throttles timers in background pages to once per second, and after five minutes hidden applies intensive throttling that aligns chained timers to once per minute. Safari and Firefox apply their own budgets, and mobile browsers may freeze the page entirely. WebSocket message events are generally still delivered, but any logic driven by setTimeout or setInterval — client heartbeats, pong-timeout checks, reconnect delays — runs late, sometimes very late.
That produces two opposite bugs. A client that uses a timer to detect a dead server fires its “no pong within 5 s” check a minute late, sees a stale timestamp, and wrongly declares the connection dead. And a client whose socket genuinely died while hidden cannot run its reconnect timer promptly, so it stays disconnected until the user returns — and then waits for a backoff delay that was computed as if no time had passed.
Resolution #
Make the client visibility-aware. While hidden, stop client-side liveness judgements based on timers and let the server’s heartbeat carry the connection — the browser still answers server pings automatically, without any JavaScript. When the tab becomes visible, run an immediate health check: if the socket is closed, reconnect now (no backoff); if it is open, ask the server what changed since the last sequence you applied, so the UI catches up in one round trip.
const VISIBLE_PONG_TIMEOUT_MS = 5_000; // only enforced while visible
const RESYNC_ON_SHOW_AFTER_MS = 15_000; // shorter hides need no resync
export class VisibilityAwareClient {
private ws: WebSocket | null = null;
private lastSeq = 0;
private hiddenAt = 0;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private connect: () => WebSocket, private applyUpdate: (u: any) => void) {
document.addEventListener('visibilitychange', this.onVisibility);
this.open();
}
private open() {
this.ws = this.connect();
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') return this.clearPongTimer();
if (typeof msg.seq === 'number') this.lastSeq = msg.seq; // track for resync
this.applyUpdate(msg);
};
this.ws.onopen = () => this.ws!.send(JSON.stringify({ type: 'resume', afterSeq: this.lastSeq }));
this.ws.onclose = () => {
// Hidden: leave reconnection to the visibility handler; timers are unreliable.
if (document.visibilityState === 'visible') this.scheduleReconnect();
};
}
private onVisibility = () => {
if (document.visibilityState === 'hidden') {
this.hiddenAt = Date.now();
this.clearPongTimer(); // never judge liveness on a throttled timer
return;
}
const hiddenFor = Date.now() - this.hiddenAt;
if (!this.ws || this.ws.readyState >= WebSocket.CLOSING) {
this.open(); // dead while hidden: reconnect immediately
return;
}
// Alive: verify with a ping, and fetch anything missed during a long hide.
this.sendPing();
if (hiddenFor > RESYNC_ON_SHOW_AFTER_MS) {
this.ws.send(JSON.stringify({ type: 'resume', afterSeq: this.lastSeq }));
}
};
private sendPing() {
this.ws?.send(JSON.stringify({ type: 'ping' }));
this.pongTimer = setTimeout(() => this.ws?.close(4000, 'pong timeout'), VISIBLE_PONG_TIMEOUT_MS);
}
private clearPongTimer() {
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pongTimer = null;
}
private scheduleReconnect() { /* jittered backoff — see the backoff guide */ }
}
The resume message asks the server for everything after lastSeq; the server either replays from a buffer or returns a fresh snapshot when the gap is too large. That server half is covered in resuming WebSocket sessions after reconnect, and the same lastSeq bookkeeping protects against the reordering described in handling out-of-order WebSocket messages.
You may also choose to close the socket after a long hide to save server resources — useful for dashboards with thousands of idle viewers. If you do, close with a normal code after, say, ten minutes hidden, and rely on the visibility handler to reopen it. The trade-off is one reconnect per returning user versus holding an idle connection per background tab.
Verification #
In Chrome, open DevTools, switch to another tab for a few minutes, and switch back. The console should show no pong-timeout closes while hidden, and a resume frame followed by catch-up updates within one round trip of returning. To force the intensive throttling path without waiting, use the chrome://flags entry for intensive wake-up throttling policy, or simply leave the tab hidden for more than five minutes on battery.
Server-side, compare reconnect rates by the reason the client reports. Before the change, hidden-tab false positives show up as a steady stream of pong timeout reconnects at night; afterwards that category should almost disappear, while resume requests with large gaps show up in the morning when users return.
Operational checklist #
FAQ #
Do WebSocket messages still arrive in a background tab? #
In desktop browsers, yes — incoming messages continue to fire message events, although the handlers may run in slower task slots. On mobile, the browser can freeze or discard the page, in which case nothing runs until the user returns and the socket is often gone.
Why does my background tab keep reconnecting? #
Almost always a client-side timer — a pong timeout or a heartbeat check — firing late because of throttling, seeing a stale timestamp, and closing the socket. Suspend timer-based liveness checks while hidden and rely on the server’s ping frames instead.
Should I use a Web Worker to avoid throttling? #
Dedicated workers are throttled less aggressively in some browsers, and moving the socket into a worker can help. A SharedWorker that owns one socket for all tabs is even better, since only one connection needs keeping alive. But the visibility-aware approach is simpler and works everywhere.
Does this matter on mobile Safari? #
More than anywhere. iOS suspends background pages quickly and the socket usually dies; the reliable pattern is exactly the one above — assume nothing while hidden and do a fast reopen plus resume on visibilitychange. See WebSockets on mobile Safari background tabs.
Related #
- Reconnecting WebSockets After a Network Change — the probe-and-replace pattern this reuses.
- Handling WebSocket Disconnects Gracefully — the core reconnect state machine.
- Implementing WebSocket Ping-Pong in Node.js — the server heartbeat that keeps hidden tabs alive.
- Queueing WebSocket Messages While Offline — protecting outgoing actions across the gap.
Back to Auto-Reconnection Strategies.