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:

  1. Short suspension. The socket survives; queued messages are delivered when the page resumes. Everything looks fine, if briefly stale.
  2. 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 close event — sometimes immediately, sometimes only after it tries to send.
  3. 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.

A background trip on iOS The user switches apps, the page is suspended five seconds later, the server terminates the silent connection at forty-five seconds, and when the user returns at ninety seconds the page's visibility event triggers a probe, a reconnect and a resume. A background trip on iOS no JavaScript runs user switches apps (0 s) page JS suspended (5 s) server: no pong, terminate (45 s) user returns (90 s) pageshow / visibilitychange (90.2 s) probe fails → reconnect + resume (91 s) Nothing the page does between 5 s and 90 s happens — plan only for the moment it resumes
Background time on iOS is dead time for the page; recovery happens at resume.

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.

What to use while the page is away While a page is suspended on iOS, a kept-open socket does nothing; Web Push for installed PWAs and native push both deliver with permission; resuming on return costs one round trip and needs no permission. What to use while the page is away Works when suspended Needs permission Latency on return Keep socket open no no probe + resume Web Push (installed PWA) yes yes user taps notification Native app + APNs yes yes instant Resume on return n/a no one round trip Web Push on iOS requires the site to be added to the Home Screen as a web app
Nothing runs in the background tab; notifications are the only way to reach a user who has left.

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.

Resume after a long background trip When the user returns, the page marks its data stale and pings over the old socket; with no pong within 1.2 seconds it opens a new socket and resumes from its last sequence, receiving the missed events or a snapshot. Resume after a long background trip User Page Server returns to Safari mark UI stale ping on old socket no pong in 1.2 s new socket, resume afterSeq missed events / snapshot The user sees 'updating…' for about two seconds instead of silently stale data
Assume the socket died while you were away, and prove otherwise quickly.

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.

Back to Browser Compatibility & Polyfills.