A React Native live-auction app stops receiving bids after the user returns from the background; why, and how should the WebSocket reconnect?
answer
- the OS suspends a backgrounded app
- the socket can look OPEN but be dead
- reconnect on returning to active
- exponential backoff with full jitter
- resubscribe, then resync missed bids
basics
~20 sA suspended app's socket stops being serviced and is often dropped without a close JavaScript ever sees. On returning to active, replace a suspect socket, reconnect with capped exponential backoff plus jitter, resubscribe, and fetch the bids missed meanwhile.
solid answer
~50 sA phone does not keep a backgrounded app's JavaScript running: after a short time the OS suspends it, timers and socket callbacks stop, and the connection is torn down by the OS, the server's idle timeout or a NAT along the way, often without a `close` event reaching JavaScript. On Android, React Native's WebSocket client also has no read timeout, so a half-dead connection can sit in `OPEN` indefinitely. So I treat the socket as suspect on every return to the `active` app state: if it is not `OPEN` or a heartbeat does not come back quickly, I close it and reconnect. Reconnects use exponential backoff with jitter and a cap, reset after a stable connection, and skip when the close code was my own `1000`. After reconnecting I resubscribe to the lot and reload current state over HTTP, because bids placed while suspended were never delivered.
code
typescript · 50 linesimport { AppState } from 'react-native';
export function createBidConnection(url: string, onBid: (raw: string) => void) {
let ws: WebSocket | null = null;
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let stableTimer: ReturnType<typeof setTimeout> | undefined;
let wanted = true;
const connect = () => {
const socket = new WebSocket(url);
ws = socket;
socket.onopen = () => {
stableTimer = setTimeout(() => { attempt = 0; }, 10_000);
// resubscribe and resync missed bids here
};
socket.onmessage = (e) => onBid(e.data);
socket.onclose = () => {
clearTimeout(stableTimer);
if (!wanted || ws !== socket) return; // stale socket or intentional close
const delay = Math.random() * Math.min(30_000, 500 * 2 ** attempt);
attempt += 1;
timer = setTimeout(connect, delay);
};
};
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
wanted = true;
if (!ws || ws.readyState !== WebSocket.OPEN) {
clearTimeout(timer);
attempt = 0;
connect();
}
} else if (state === 'background') {
wanted = false;
clearTimeout(timer);
ws?.close(1000, 'backgrounded');
}
});
connect();
return () => {
wanted = false;
clearTimeout(timer);
clearTimeout(stableTimer);
sub.remove();
ws?.close();
};
}go deeper
Recall that a backgrounded app's socket can die silently and that you reconnect when the app becomes active again.
Explain the triggers (close codes, return to active, connectivity back) and why React Native can show OPEN on a dead socket.
Design one connection manager with capped exponential backoff and full jitter, stable-connection resets, deliberate background closes and a resync step for missed messages.
Balance real-time delivery against battery and server load: when to rely on push instead of a socket, reconnect storms after deploys, and replay guarantees the backend must offer.
## Why the socket dies A WebSocket is a long-lived TCP connection, and a mobile app is not a long-lived process: - **Suspension.** Shortly after an app moves to the background, the OS suspends it unless it holds a qualifying background mode. JavaScript stops running, so no timers fire and no `onmessage` or `onclose` handlers run. - **Silent teardown.** While suspended, the connection can be closed by the OS, by the server's idle timeout, or by a NAT or carrier middlebox that forgets the mapping. The app may never receive a close frame. - **Network changes.** Switching from Wi-Fi to cellular gives the phone a new address; the old TCP connection cannot survive that. - **No read timeout.** React Native's Android WebSocket client is built with a 10-second connect timeout and **no read timeout**, and neither platform surfaces pongs to JavaScript. A half-open connection can therefore still report `readyState === OPEN` while nothing arrives. For the auction, that means the user returns to a screen showing a stale top bid, with no error anywhere. ## Reconnect triggers Reconnect logic should react to three signals: 1. **`close` event** with a code other than your own intentional `1000`. React Native reports failures as `1006` with the native message in `reason`. 2. **Return to the foreground.** When the app state changes to `active`, treat the socket as suspect: if it is not `OPEN`, reconnect; if it is, send a heartbeat and reconnect when it goes unanswered. 3. **Connectivity restored.** A reachability library's online event is a good moment to retry immediately instead of waiting out a long backoff. When the app goes to the background, it is often better to **close the socket deliberately** with `1000` and reopen on return. That gives the server a clean signal and avoids trusting a zombie connection. ## Backoff with jitter Thousands of phones reconnecting at the same instant after a server deploy can knock the server over again. The standard defence: - **Exponential backoff:** wait `base * 2^attempt` (for example 500 ms, 1 s, 2 s, 4 s...). - **Cap:** never wait more than, say, 30 seconds. - **Full jitter:** pick a random delay between 0 and the computed value so clients spread out. - **Reset:** set `attempt` back to 0 once a connection has stayed open for a while, not merely on `open`, or a server that accepts and immediately drops will be hammered. - **Respect intent:** do not reconnect after your own `close(1000)`, after logout, or while the app is in the background. | Delay strategy | Behaviour after a server restart | |---|---| | Fixed 1 s retry | Every client hits the server each second in lockstep | | Exponential, no jitter | Load arrives in synchronised waves | | Exponential with full jitter | Reconnects spread across the window | ## After the reconnect: resynchronise A new socket only delivers **future** messages. For an auction, missed bids matter: 1. Resubscribe to the lot (the server does not remember the old socket's subscriptions). 2. Fetch the current lot state over HTTP, or ask the server to replay from the last bid id you saw. 3. De-duplicate by bid id, because a replay and the live stream can overlap. 4. Only then re-enable the "place bid" button, so a user never bids against a stale price. ## Testing the reconnect path Reconnect code is rarely exercised in development, so provoke the failures on purpose: - **Background for a few minutes** on a real device, then return and confirm the connection manager replaces the socket and resyncs. - **Toggle airplane mode** mid-auction and check that retries back off rather than spinning. - **Restart the server** with several simulators connected and watch the reconnects spread out instead of arriving together. - **Switch Wi-Fi to cellular** on a device to produce the half-open case that only a heartbeat detects. ## Putting it in one place Wrap all of this in one connection manager outside any screen: it owns the socket, the attempt counter, the timers and the subscriptions, listens to app-state changes, and exposes subscribe/unsubscribe to screens. Screens stay simple, and there is exactly one reconnect loop in the app. In an interview, the strong answer explains **why** the socket dies silently on a phone, gives the **foreground check**, the **jittered backoff**, and the **resync step**. The last one is the part most candidates forget.
- Why reset the backoff counter after a period of stability rather than in onopen?A server that accepts the handshake and then drops the connection would otherwise reset the counter on every attempt, so the client would retry at the base delay forever. Waiting until the socket has stayed open for a few seconds proves the connection is really healthy.
- After reconnecting, how do you avoid showing a bid twice?Give every bid a server-assigned id or sequence number. Track the last one applied; when you replay from that point or reload lot state over HTTP, ignore messages whose id you have already applied, so replay and live stream can overlap safely.
- Should you keep the socket open in the background to receive outbid alerts?Generally no: the OS will suspend the app anyway, so the socket cannot be relied on there. Close it on backgrounding and deliver time-critical alerts such as being outbid through push notifications, then resync over HTTP and reopen the socket when the app returns to the foreground.
saying these in an interview costs you the question
- If readyState says OPEN, the connection is healthy.
- The OS always delivers a close event when a backgrounded app's socket drops.
- Reconnect immediately in a tight loop until it works.
- A fresh connection automatically receives the messages missed while disconnected.
- Reset the backoff counter as soon as onopen fires.