WebSockets on mobile Safari background tabs #
On desktop, your live app stays connected all day. On an iPhone, a user switches to Messages for thirty seconds, comes back, and the page shows data from before they left; sometimes it recovers after a long pause, sometimes it sits there frozen until they pull to refresh. Server logs show the connection closing with 1006 a little after the user left, or — more confusingly — staying open on the server long after the phone stopped answering. iOS aggressively suspends web pages that are not in the foreground, and a WebSocket cannot run while its page is suspended. You cannot change that behaviour. You can make the page notice it instantly on return, recover in one round trip, and use push notifications for anything the user must not miss while away.
Root cause #
iOS manages battery and memory by suspending apps that are not visible, and Safari applies the same policy to web pages: when the user switches apps, locks the screen or moves Safari to another tab, JavaScript for the page stops running shortly after — typically within seconds, subject to heuristics Apple does not document. A suspended page cannot answer pings, run timers or process messages. Depending on how long the suspension lasts and what the network does meanwhile, three things can happen to the socket:
- Short suspension. The socket survives; queued messages are delivered when the page resumes. Everything looks fine, if briefly stale.
- Longer suspension. The server’s heartbeat gets no pong and terminates the connection, or the OS tears it down. On resume, the page sees a
closeevent — sometimes immediately, sometimes only after it tries to send. - Page discarded. Under memory pressure, iOS unloads the page entirely. On return, it reloads from scratch — or from the back-forward cache, which restores the JavaScript heap with a dead socket inside it.
Cases 2 and 3 are where apps get stuck: the page resumes believing its socket is OPEN because no event has fired yet, shows stale data, and waits for a message that will never come.
Resolution #
Design for resume, not for staying alive. On visibilitychange to visible and on pageshow (including back-forward-cache restores), assume the socket may be dead regardless of readyState: send an application-level probe with a short deadline, reconnect immediately if it fails, and request everything after the last sequence you applied. Meanwhile, show the user that data is refreshing rather than letting stale numbers look current.
const RESUME_PROBE_TIMEOUT_MS = 1_200; // a live socket on a phone answers well inside this
const STALE_AFTER_HIDDEN_MS = 5_000; // hides longer than this mark the UI as refreshing
export function installResumeRecovery(opts: {
getSocket: () => WebSocket | null;
reconnect: () => void; // opens a new socket; resumes from lastSeq on open
requestResume: () => void; // sends { type: 'resume', afterSeq } on a live socket
setStale: (stale: boolean) => void; // UI hint: "updating…"
}) {
let hiddenAt = 0;
const check = () => {
const hiddenFor = hiddenAt ? Date.now() - hiddenAt : 0;
hiddenAt = 0;
if (hiddenFor > STALE_AFTER_HIDDEN_MS) opts.setStale(true);
const ws = opts.getSocket();
if (!ws || ws.readyState !== WebSocket.OPEN) { opts.reconnect(); return; }
// readyState can lie after a suspension: prove the socket works.
let answered = false;
const onMsg = (e: MessageEvent) => {
if (typeof e.data === 'string' && e.data.includes('"type":"pong"')) {
answered = true;
ws.removeEventListener('message', onMsg);
opts.requestResume(); // catch up on anything missed while suspended
}
};
ws.addEventListener('message', onMsg);
ws.send(JSON.stringify({ type: 'ping' }));
setTimeout(() => {
if (answered) return;
ws.removeEventListener('message', onMsg);
try { ws.close(4000, 'resume probe failed'); } catch { /* already closing */ }
opts.reconnect(); // no backoff: this is recovery, not an outage
}, RESUME_PROBE_TIMEOUT_MS);
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') hiddenAt = Date.now();
else check();
});
// bfcache restore: the heap came back, the socket almost certainly did not.
window.addEventListener('pageshow', (e) => { if (e.persisted) opts.reconnect(); });
// Leaving the page: close cleanly so the server frees resources now, not after a timeout.
window.addEventListener('pagehide', () => opts.getSocket()?.close(1001, 'page hidden'));
}
Clear the stale indicator when the resume response arrives. The resume request and the server’s replay-or-snapshot response are covered in resuming WebSocket sessions after reconnect, and the same visibility handling is generalized for all browsers in WebSocket reconnect with the Page Visibility API.
Closing on pagehide is deliberate. Keeping a socket open for a page iOS is about to suspend helps nobody: the server would hold a connection that cannot answer until its heartbeat times it out. An explicit 1001 frees it immediately, and pages with open WebSockets are also less likely to be admitted to the back-forward cache in some browsers, so closing improves instant back navigation too.
Edge cases #
Web Push requirements on iOS. Safari on iOS supports Web Push only for web apps added to the Home Screen, and only after the user grants notification permission from a user gesture. For events users must not miss — a direct message, an alert — deliver them through push as well as the socket, as described in delivering to offline users with push fallback.
Low Power Mode and network changes. Low Power Mode shortens how long pages keep running after being backgrounded, and returning users are often on a different network than when they left. Combine this recovery with the probe-and-replace approach from reconnecting WebSockets after a network change.
In-app browsers. Links opened inside other apps’ web views (social apps, mail clients) run with their host app’s lifecycle, which may be even more aggressive. The resume-based design works there unchanged, which is another reason not to rely on background execution.
Verification #
Test on a real device, not a simulator: connect Safari’s Web Inspector from a Mac, open the app, switch to another app for ten seconds, then for two minutes, then lock the screen for five minutes. On each return, the console should log the probe and — for the longer absences — a reconnect and a resume, with the stale indicator clearing within a second or two. On the server, confirm 1001 closes arrive when the user leaves (from pagehide) instead of heartbeat timeouts minutes later.
Watch field metrics too: time from visibilitychange to first fresh data, split by platform. iOS should converge to one or two round-trip times after these changes.
Operational checklist #
FAQ #
Do WebSockets stay connected when Safari is in the background on iPhone? #
Not reliably. iOS suspends background pages within seconds, after which no JavaScript runs; the connection may survive a short absence but typically dies during longer ones. Design the page to recover on return rather than to stay connected.
Why does my iOS web app show stale data after switching back? #
The page resumed with a socket that looks open but is dead, and nothing has told it otherwise yet. Probe the socket on visibilitychange and reconnect if it does not answer quickly.
Can a service worker keep the WebSocket alive on iOS? #
No. Service workers are terminated when idle and cannot hold long-lived connections on any platform. For background delivery, use Web Push (for Home Screen web apps) or a native app with APNs.
Is this different on Android Chrome? #
Android is more lenient with recently used tabs but still freezes background pages, especially under battery saver. The same resume-based design works well there, so implement it once for all mobile browsers.
Related #
- WebSocket Reconnect with the Page Visibility API — the cross-browser version of this pattern.
- Reconnecting WebSockets After a Network Change — probing after interface switches.
- Delivering to Offline Users with Push Fallback — reaching users who have left.
- Falling Back from WebSockets to SSE — the other compatibility path.
Back to Browser Compatibility & Polyfills.