TL;DR
- Both platforms’ documentation allow the OS to cut a backgrounded app off from the network. Apple’s archived networking guide says an app “may be suspended… which means that it can no longer handle network traffic” and “existing connections may even close”. Android’s Doze page says that once a device has been left unplugged, stationary and screen-off for a while, Doze “suspends network access” and defers jobs, syncs and standard alarms.
- While the app is suspended or deferred, its code does not run, so it cannot notice the loss while it is away. The only reliable moment to find out is when the app comes back to the foreground.
- A WebSocket in
readyState1 (OPEN) after resume has told you what it believed at the last event it processed. In a Linux stand-in (a frozen client process), the server’ssend()calls kept succeeding,bufferedAmountstayed 0, and nothing on the server showed a problem for 3 seconds, until my own heartbeat deadline fired. - A routine that works from those facts: on every “became active”, send an application-level ping with a deadline; if no answer, close, reconnect, and catch up on what you missed. I ran it against four cases and it behaved as intended in all four.
- I tested no iPhone and no Android phone. Everything experimental here is a stand-in, and I say where that matters.
What the platforms say they may do
I read these pages at their original sites while writing:
| Platform | Documented behaviour | Source |
|---|---|---|
| iOS | The app “may be suspended when it goes into the background, which means that it can no longer handle network traffic. In some cases, existing connections may even close while your app is suspended.” | Apple, Platform-Specific Networking Technologies (archived) |
| Android, Doze | After the device has been left unplugged and stationary with the screen off for a while, the system enters Doze. While in Doze it “suspends network access”, ignores wake locks and defers standard alarms to a maintenance window. | Android, Doze and App Standby |
| Android, App Standby | “defers background network activity for apps with no recent user activity”. | same page |
| Android, advice | “If your app requires a persistent connection to the network to receive messages, use Firebase Cloud Messaging (FCM) if possible.” High-priority messages only for ones that result in a notification. | same page |
Two cautions about this table. The Apple page is from Apple’s documentation archive; it is old, and I did not find a current Apple page that says the same thing in one sentence, so treat it as the principle, not the latest wording. And neither page gives numbers for how long the app has before it is cut off, or exactly when; they describe what the system may do. That is the point: a design that needs a particular duration is a design that assumes something the platforms do not promise.
React Native’s AppState is the API through which a JavaScript app learns about this: the states are active, background, and on iOS inactive, with a change event (React Native docs). It tells you about transitions you were awake for. It cannot tell you what happened to your socket while you were not running.
Four rules that follow, if you take the documents seriously
- Decide at the transition, not on a timer. While the app is suspended, or Doze is deferring its alarms, its timers do not fire when you expect. A heartbeat in the app cannot notice the connection dying; the server’s heartbeat can notice the app’s silence, but it can only act on its side. The reliable client-side moment is the foreground event.
- A socket that reports
OPENafter resume must prove it. Send a message that the peer’s application must answer, and give it a deadline. Browser WebSocket APIs expose no ping frame, so this has to be an application-level message. (The React Native source I read, version 0.87.1, has aping()method on its WebSocket but nopongevent I could find, so you cannot wait for the answer there either.) - If it does not prove it, rebuild and catch up. Close, reconnect, and ask the server for everything since the last thing you saw. Reconnecting alone silently drops what happened while you were away.
- The server must be able to give up on a client. A frozen client is not a closed client. Without a heartbeat deadline on the server, you keep state for connections nobody is on.
Notice what is not in the list: tricks to stay alive in the background. For the use cases the platforms bless (calls, audio, location, downloads), they have dedicated mechanisms with their own review rules, and I did not test any of them. For “tell the user something happened”, the Android page points to FCM. I read only the Android side of that advice, and I did not read Apple’s current push documentation.
A frozen client, seen from the server
The Linux stand-in for “the OS stopped running my app” is SIGSTOP on the client process. It is not what iOS or Android do exactly: here, the kernel and the TCP connection stay up, and only the code stops. It models one thing, which is that the app cannot respond to anything while the other end keeps going. The lab starts a ws server that sends a message and a ping every 500 ms and counts a client as gone after 3000 ms without a pong. A client process connects, then gets SIGSTOP at 2.0 s and SIGCONT at 9.0 s.
// What does a server see when the client process is frozen (SIGSTOP) but its kernel and network are fine?
// Linux stand-in for "the OS suspended the app". Run: node freeze_lab.mjs
import { WebSocketServer } from "ws";
import { spawn } from "node:child_process";
const t0 = Date.now(); const at = () => `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`;
const wss = new WebSocketServer({ port: 0 });
await new Promise((r) => wss.on("listening", r));
const port = wss.address().port;
const client = spawn(process.execPath, ["-e", `
const WebSocket = require("ws"); let n = 0;
const ws = new WebSocket("ws://127.0.0.1:${port}");
ws.on("message", () => n++);
ws.on("close", (c) => { console.log("client: close event, code " + c + ", messages seen: " + n); process.exit(0); });
ws.on("error", (e) => console.log("client: error " + e.code));
`], { stdio: "inherit", cwd: process.cwd() });
wss.on("connection", (ws) => {
let seq = 0, lastPong = Date.now(), sent = 0, acked = 0;
ws.on("pong", () => { lastPong = Date.now(); });
const tick = setInterval(() => {
ws.send(`m${++seq}`, () => acked++); // callback = handed to the kernel
sent++;
ws.ping();
const quiet = Date.now() - lastPong;
if (seq % 2 === 0) console.log(`${at()} server: sent=${sent} handed-to-kernel=${acked} bufferedAmount=${ws.bufferedAmount} quiet-for=${quiet} ms readyState=${ws.readyState}`);
if (quiet > 3000) {
console.log(`${at()} server: no pong for ${quiet} ms -> declare the client gone, terminate()`);
clearInterval(tick); ws.terminate();
}
}, 500);
ws.on("close", () => console.log(`${at()} server: close event`));
});
setTimeout(() => { console.log(`${at()} >>> SIGSTOP the client process`); client.kill("SIGSTOP"); }, 2000);
setTimeout(() => { console.log(`${at()} >>> SIGCONT the client process`); client.kill("SIGCONT"); }, 9000);
setTimeout(() => process.exit(0), 11000);
[ 1.2s] server: sent=2 handed-to-kernel=1 bufferedAmount=0 quiet-for=497 ms readyState=1
[ 2.0s] >>> SIGSTOP the client process
[ 2.2s] server: sent=4 handed-to-kernel=3 bufferedAmount=0 quiet-for=501 ms readyState=1
[ 3.2s] server: sent=6 handed-to-kernel=5 bufferedAmount=0 quiet-for=1503 ms readyState=1
[ 4.2s] server: sent=8 handed-to-kernel=7 bufferedAmount=0 quiet-for=2504 ms readyState=1
[ 4.7s] server: no pong for 3004 ms -> declare the client gone, terminate()
[ 4.7s] server: close event
[ 9.0s] >>> SIGCONT the client process
client: close event, code 1006, messages seen: 9
(Node 20.19.2, ws 8.22.0, Linux 6.12, loopback.) What this shows:
- While the client was frozen, the server’s
send()calls kept succeeding andbufferedAmountstayed at 0. Nothing about the sending side looks wrong. Only the missing pongs do, and only against a deadline that I chose (3 s). - The frozen client got no notification. After
SIGCONTit processed the 9 messages that the kernel had held for it, and then learned the connection was closed, as code 1006. Before that moment, the client’s socket would have read asOPENto any code that looked. - Both ends see the same event at different times. How far apart that is on a real phone I did not measure, and the documents do not say. The gap is what you must plan for.
The routine: probe, rebuild, catch up
The implementation takes the lifecycle source, a connect function and a catch-up function as parameters, so it does not depend on React Native and I could test it on Node with a fake AppState.
// On "active": do not trust readyState. Prove the socket is alive, otherwise rebuild it and catch up.
export function keepFresh({ lifecycle, connect, catchUp, probeMs = 2000, log = () => {} }) {
let socket = null, busy = null;
const probe = (ws) => new Promise((resolve) => { // a round trip that the peer *application* must answer
if (!ws || ws.readyState !== 1) return resolve(false);
const done = (ok) => { clearTimeout(timer); ws.removeEventListener("message", onMessage); resolve(ok); };
const onMessage = (e) => { if (e.data === "pong") done(true); };
const timer = setTimeout(() => done(false), probeMs);
ws.addEventListener("message", onMessage);
try { ws.send("ping"); } catch { done(false); }
});
async function ensure(reason) {
if (busy) return busy; // "active" can fire repeatedly
return (busy = (async () => {
if (await probe(socket)) { log(`${reason}: socket proved alive, nothing to do`); return; }
log(`${reason}: socket is ${socket ? "readyState " + socket.readyState : "missing"} or silent -> rebuild`);
try { socket?.close(); } catch {}
socket = await connect();
await catchUp(); // whatever was missed while we were away
})().finally(() => { busy = null; }));
}
lifecycle.addEventListener("change", (state) => { if (state === "active") ensure("foreground"); });
return { start: () => ensure("start"), get socket() { return socket; } };
}
Two details that matter. busy makes concurrent triggers share one run: active events can fire repeatedly, and without the guard each would start its own probe and its own rebuild. And the probe resolves false for anything that is not OPEN, including a closed socket, so the same function handles “dead” and “silent”.
Four cases, each against a real ws server on loopback, with a probe deadline of 500 ms to keep the test short:
import { WebSocketServer, WebSocket } from "ws";
import { EventEmitter } from "node:events";
import { keepFresh } from "./keep_fresh.mjs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const wss = new WebSocketServer({ port: 0 }); await new Promise((r) => wss.on("listening", r));
const serverSide = [];
wss.on("connection", (ws) => { serverSide.push(ws); ws.on("message", (m) => { if (m.toString() === "ping") ws.send("pong"); }); });
const url = `ws://127.0.0.1:${wss.address().port}`;
class FakeAppState extends EventEmitter { addEventListener(t, f) { this.on(t, f); } } // same shape as React Native's AppState.addEventListener
const lifecycle = new FakeAppState();
let catchUps = 0, connects = 0;
const t0 = Date.now(); const log = (m) => console.log(`[${String(Date.now() - t0).padStart(5)} ms] ${m}`);
const app = keepFresh({
lifecycle, log, probeMs: 500,
connect: async () => { connects++; const c = new WebSocket(url); await new Promise((r) => c.on("open", r)); c.on("error", () => {}); return c; },
catchUp: async () => { catchUps++; },
});
await app.start(); await sleep(100);
log(`connects=${connects} catchUps=${catchUps}`);
log("--- case 1: app goes background and comes back; connection is fine");
lifecycle.emit("change", "background"); await sleep(100); lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);
log("--- case 2: the connection silently died while we were away (server stops reading, so no pong)");
serverSide.at(-1)._socket.pause();
log(`client still thinks: readyState=${app.socket.readyState} (1 = OPEN)`);
lifecycle.emit("change", "active"); await sleep(900);
log(`connects=${connects} catchUps=${catchUps}`);
log("--- case 3: the server closed it while we were away");
serverSide.at(-1).terminate(); await sleep(100);
lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);
log("--- case 4: three 'active' events in a row");
lifecycle.emit("change", "active"); lifecycle.emit("change", "active"); lifecycle.emit("change", "active"); await sleep(300);
log(`connects=${connects} catchUps=${catchUps}`);
process.exit(0);
[ 0 ms] start: socket is missing or silent -> rebuild
[ 113 ms] connects=1 catchUps=1
[ 113 ms] --- case 1: app goes background and comes back; connection is fine
[ 217 ms] foreground: socket proved alive, nothing to do
[ 516 ms] connects=1 catchUps=1
[ 516 ms] --- case 2: the connection silently died while we were away (server stops reading, so no pong)
[ 517 ms] client still thinks: readyState=1 (1 = OPEN)
[ 1017 ms] foreground: socket is readyState 1 or silent -> rebuild
[ 1418 ms] connects=2 catchUps=2
[ 1419 ms] --- case 3: the server closed it while we were away
[ 1520 ms] foreground: socket is readyState 3 or silent -> rebuild
[ 1821 ms] connects=3 catchUps=3
[ 1822 ms] --- case 4: three 'active' events in a row
[ 1822 ms] foreground: socket proved alive, nothing to do
[ 2122 ms] connects=3 catchUps=3
Case 2 is the interesting one. The client still reports readyState=1, the probe times out after its 500 ms, and the routine rebuilds and runs a catch-up. A check on readyState alone would have sailed past it. In case 4, three active events produced one probe and no extra reconnects.
The test makes the “silent” server by pausing its socket reads, which is a lab device. A real silent death is a network change, a NAT timeout, or a suspended process, and I did not reproduce any of those.
What to put on the server
Your server’s heartbeat is a different job: free resources and mark presence. A WebSocket library such as ws exposes ping frames and terminate() (as used above). The freeze lab’s server reaped the frozen client in 3 s with a rule of “no pong for 3 s”. The deadline is a trade-off. A short one frees state sooner but also evicts a client that is merely on a bad network; a long one holds state for dead clients. Choose it from how expensive a wrongly evicted client is for you, then measure how often it happens.
For “wake the user”, rule 4’s counterpart: use push, not a socket. The server sends a push, and the app opens the socket and catches up. On Android that is what the documentation recommends: an FCM high-priority message gives the app temporary network access even in Doze. I read nothing equivalent from Apple and say nothing about iOS here. Either way the foreground routine above has to exist anyway: the push is a hint, not the data.
Where this routine fits
| Situation | Use this routine? |
|---|---|
| Live data that matters only while the app is in the foreground (chat view, dashboard, a collaborative document) | Yes. This is the case it is built for. |
| You must know about events while the app is in the background | No. Use the platform push service to wake or notify, then run the routine on return. |
| Streaming media, calls, navigation | No. These have documented background modes of their own; I did not test any. |
| A plain web page in a desktop browser | Partly. The page visibility and network events differ, and I did not test them. |
Where the routine fails
- Trusting
readyStateafter resume. Symptom: the UI shows “connected” but nothing arrives until someone pulls to refresh. Reproduced above (case 2). Fix: probe with a deadline. - A probe with no deadline. Symptom: the app waits forever after resume. The probe here always resolves, with
falseafterprobeMs. - Rebuilding without catching up. Symptom: no error, but messages from the time away are missing. Fix: the catch-up step, based on the last id or sequence number you saw, which needs a server API for it.
- One probe per event. Symptom: reconnect storms on flaky transitions. Fix: the
busyguard, tested in case 4. - Probing with a protocol ping. Symptom: the code looks for
socket.ping()and it does not exist. Browsers have no ping method, and React Native’sping()(in the 0.87.1 source I read) has no matchingpongevent, so the probe must be an application message that the server answers. - Everyone reconnecting at the same time. If a server restart or a network event drops many clients at once, each one retries in the same instant. Add random jitter to reconnect delays. I did not test this at scale.
- A server that never gives up. Symptom: memory held for clients that are long gone. Fix: a heartbeat deadline, as in the freeze lab.
Try it
mkdir lab && cd lab && npm init -y && npm i ws@8, then save the three scripts.- Run
node freeze_lab.mjs. Success: thebufferedAmount=0lines continue afterSIGSTOP, ano pongline appears about 3 s later, and the client prints code 1006 only afterSIGCONT. - Run
node test.mjs. Success:connects=3 catchUps=3at the end, and a single “proved alive” line in case 4. - Change
probeMsintest.mjsto 50 and watch what happens in case 1 on a slower machine. Decide what the right value is for your network from your own round-trip measurements. - In your own app, replace the fake
AppStatewith the real one fromreact-nativeandconnectwith your socket factory. I have not run that combination.
What no phone confirmed
Verified: the three scripts on one machine (Linux 6.12, Node 20.19.2, ws 8.22.0), with the outputs shown. The quotations from the Apple and Android pages and the AppState state names were read at the original pages while writing.
Not verified: any iPhone or Android device, emulator, or React Native runtime. How long an app has, in practice, before suspension or Doze starts; what the OS does to the TCP connection at that moment; whether the OS keeps the socket open or closes it on a given version and device; background modes (VoIP, audio, location); push delivery and its latency; AppState event ordering on real devices; and behaviour of a real “active” burst. The SIGSTOP stand-in freezes code but leaves the network up, which is not what the platforms necessarily do. The 3 s server deadline and the 500 ms probe deadline are lab values.
After the app has been away
After the app has been away, an open socket is a claim. Ask it a question with a deadline, and if it does not answer, build a new one and ask the server what you missed.