WebSocket lifecycle in React Native apps #

Your React Native app reuses the web client’s WebSocket hook, and it mostly works — until users background the app. On iOS the connection dies silently within seconds of backgrounding, and when the user returns the app shows stale data until some later event exposes the dead socket. On Android the app keeps the socket open in the background for a while, draining battery with heartbeats nobody sees, and then the OS kills the process anyway. Messages sent while the app was in the background never arrive, because nothing delivered them. Mobile apps live in a lifecycle the browser only approximates, and a WebSocket in React Native must follow that lifecycle explicitly: connected while the app is active, deliberately closed when it is not, and recovered quickly and completely when it returns.

Root cause #

React Native exposes the app’s lifecycle through AppState: active (foreground), background, and on iOS a transient inactive state during interruptions such as the app switcher or an incoming call. The operating systems enforce their own rules on backgrounded apps. iOS suspends an app’s process shortly after it leaves the foreground unless it holds a specific background mode; a suspended app runs no JavaScript, so its socket cannot answer heartbeats and is torn down by the OS or the server. Android is more lenient in the short term but applies Doze and app-standby restrictions, and may kill background processes to reclaim memory.

Neither platform lets an ordinary app keep a WebSocket alive in the background indefinitely, and neither should — a persistent connection is exactly the kind of background activity mobile operating systems are designed to curtail. The WebSocket is therefore a foreground channel, and anything that must reach a backgrounded user goes through push notifications (APNs, FCM).

Connection policy by app state The socket is open while the app is active, kept briefly during iOS inactive interruptions, closed cleanly when the app moves to the background, and reconnected with resume when the app becomes active again. Connection policy by app state active socket open, live inactive (iOS) keep socket, pause UI background close cleanly (1001) returning reconnect + resume app switcher / call back leaves foreground AppState active Push notifications cover the background state; the socket does not
The socket follows the app's lifecycle instead of fighting it.

Resolution #

Drive the connection from AppState. On background, close the socket with 1001 after a short grace period (to absorb quick app-switcher trips) so the server releases it immediately and no battery is spent on heartbeats. On active, reconnect at once and resume from the last sequence number per stream, so the UI catches up in one round trip. Use @react-native-community/netinfo to probe the socket when the network changes while active. Register for push notifications so events that happen while backgrounded still reach the user.

import { useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BACKGROUND_CLOSE_DELAY_MS = 5_000; // absorb quick trips to the app switcher
const PROBE_TIMEOUT_MS = 2_000;

type Status = 'connecting' | 'live' | 'paused' | 'offline';

export function useMobileSocket(url: string, getTicket: () => Promise<string>, onMessage: (m: any) => void) {
const [status, setStatus] = useState<Status>('connecting');
const ws = useRef<WebSocket | null>(null);
const lastSeq = useRef(0);
const closeTimer = useRef<ReturnType<typeof setTimeout>>();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;

useEffect(() => {
let disposed = false;
let attempt = 0;

const open = async () => {
if (disposed || ws.current) return;
setStatus('connecting');
const socket = new WebSocket(`${url}?ticket=${encodeURIComponent(await getTicket())}&afterSeq=${lastSeq.current}`);
ws.current = socket;
socket.onopen = () => { attempt = 0; setStatus('live'); };
socket.onmessage = (e) => {
const msg = JSON.parse(String(e.data));
if (typeof msg.seq === 'number') {
if (msg.seq <= lastSeq.current) return; // duplicate after resume
lastSeq.current = msg.seq;
}
onMessageRef.current(msg);
};
socket.onclose = (e) => {
ws.current = null;
if (disposed || e.code === 1001) return; // deliberate close for background
setStatus('offline');
if (AppState.currentState !== 'active') return; // reconnect only in the foreground
const cap = Math.min(30_000, 1_000 * 2 ** attempt++);
setTimeout(open, Math.random() * cap);
};
};

const onAppState = (next: AppStateStatus) => {
clearTimeout(closeTimer.current);
if (next === 'active') {
void open(); // immediate: no backoff on return
} else if (next === 'background') {
closeTimer.current = setTimeout(() => {
ws.current?.close(1001, 'app backgrounded'); // server frees it now; push takes over
setStatus('paused');
}, BACKGROUND_CLOSE_DELAY_MS);
}
};

// Network changed while active: prove the socket still works, else replace it.
const unsubscribeNet = NetInfo.addEventListener((state) => {
if (AppState.currentState !== 'active' || !state.isConnected) return;
const socket = ws.current;
if (!socket || socket.readyState !== WebSocket.OPEN) { void open(); return; }
let answered = false;
const handler = (e: WebSocketMessageEvent) => { if (String(e.data).includes('"type":"pong"')) answered = true; };
socket.addEventListener('message', handler);
socket.send(JSON.stringify({ type: 'ping' }));
setTimeout(() => {
socket.removeEventListener('message', handler);
if (!answered) socket.close(4000, 'probe failed'); // onclose schedules a reconnect
}, PROBE_TIMEOUT_MS);
});

const sub = AppState.addEventListener('change', onAppState);
void open();
return () => {
disposed = true;
sub.remove();
unsubscribeNet();
clearTimeout(closeTimer.current);
ws.current?.close(1000, 'unmount');
};
}, [url, getTicket]);

return status;
}

The resume parameter (afterSeq) makes the return trip cheap: the server replays what was missed while the app was backgrounded, or sends a snapshot if the gap is too large — the protocol from resuming WebSocket sessions after reconnect. The same visibility-driven approach is used for browsers in WebSocket reconnect with the Page Visibility API; in React Native, AppState plays the role of visibilitychange.

Closing deliberately on background also simplifies the server: a 1001 close is an honest signal that the user left, which the server can use to start the push-fallback timer for undelivered messages, as described in delivering to offline users with push fallback.

Illustrative effect of closing on background Keeping the socket open in the background costs the full heartbeat battery budget and still produces frequent silent drops, while closing on background reduces heartbeat battery cost to a few percent and eliminates unnoticed drops because the app reconnects deliberately on return. Illustrative effect of closing on background per hour of background time relative battery cost of heartbeats (%) background socket drops users notice 0 25 50 75 100 60 Keep open in background Close on background
A socket held in the background costs battery and still dies — close it and resume instead.

Edge cases #

Android foreground services. Apps with a legitimate need to stay connected in the background — navigation, calls, live location sharing — can run an Android foreground service with a visible notification, and iOS offers background modes for specific categories (VoIP, audio, location). These are platform-policed exceptions, not a general way to keep a chat socket alive.

JavaScript engine timers in the background. Timers may not fire while the app is backgrounded, so the background close delay may run late or on return. Treat it as best-effort; the server’s heartbeat timeout is the backstop.

Authentication on return. A token that expired while the app was backgrounded makes the first reconnect fail. Fetch a fresh ticket in open(), as above, rather than reusing a cached one.

Verification #

Test on physical devices with a debugger attached. Background the app for ten seconds and for five minutes; on return, the logs should show an immediate reconnect with afterSeq and the missed messages replayed within a second or two. On the server, confirm that backgrounding produces a 1001 close after the grace period rather than a heartbeat timeout later. Toggle airplane mode while active and confirm the NetInfo probe replaces the socket quickly. Finally, send a message to the user while the app is backgrounded and confirm a push notification arrives and tapping it opens the app into a resumed, current view.

Background and return The user switches away, the app closes its socket with 1001 after five seconds, a new message triggers a push notification, the user opens the app, which reconnects with afterSeq 512 and receives the replay before going live. Background and return User App Server Push service switches away close 1001 after 5 s new message: push (user away) notification opens app (active) connect afterSeq=512 replay 513–519, live Push covers the gap; resume makes the return instant
Foreground: WebSocket. Background: push. Return: resume.

Operational checklist #

FAQ #

Do WebSockets work in React Native? #

Yes. React Native provides a WebSocket implementation compatible with the browser API. The difference is the app lifecycle: you must manage the connection across foreground and background transitions yourself.

Can a React Native app keep a WebSocket open in the background? #

Not reliably. iOS suspends backgrounded apps within seconds, and Android restricts background activity. Close the socket when backgrounded, use push notifications for background delivery, and reconnect with resume when the app returns.

Why does my React Native app show stale data after returning from background? #

The socket died while the app was suspended, but no close event has fired yet, so the app believes it is still connected. Reconnect (or probe) on AppState becoming active, and resume from the last sequence number.

Should I use Socket.IO in React Native? #

It works, and it handles reconnection for you, but you still need to align it with AppState — disconnect on background and reconnect on active — to avoid battery drain and stale state.

Back to React WebSocket Custom Hooks.