WebSockets & Real-Time
Reconnect strategies and battery-aware polling fallbacks for live data on Expo SDK 57 without draining battery in background.
Search across all documentation pages
Reconnect strategies and battery-aware polling fallbacks for live data on Expo SDK 57 without draining battery in background.
Quick-reference recipe card - copy-paste ready.
// src/realtime/useReconnectingSocket.ts
import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
type Options = {
url: string;
onMessage: (data: unknown) => void;
maxBackoffMs?: number;
};
export function useReconnectingSocket({ url, onMessage, maxBackoffMs = 30_000 }: Options) {
const wsRef = useRef<WebSocket | null>(null);
const attemptRef = useRef(0);
const [connected, setConnected] = useState(false);
const appState = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
function connect() {
if (closed || appState.current !== "active") return;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
attemptRef.current = 0;
setConnected(true);
};
ws.onmessage = (event) => {
try {
onMessage(JSON.parse(String(event.data)));
} catch {
onMessage(event.data);
}
};
ws.onclose = () => {
setConnected(false);
if (closed || appState.current !== "active") return;
const delay = Math.min(1000 * 2 ** attemptRef.current, maxBackoffMs);
attemptRef.current += 1;
timer = setTimeout(connect, delay * (0.5 + Math.random() * 0.5));
};
ws.onerror = () => ws.close();
}
const sub = AppState.addEventListener("change", (next) => {
appState.current = next;
if (next === "active") {
connect();
} else {
wsRef.current?.close();
wsRef.current = null;
setConnected(false);
}
});
connect();
return () => {
closed = true;
if (timer) clearTimeout(timer);
wsRef.current?.close();
sub.remove();
};
}, [url, maxBackoffMs, onMessage]);
return { connected };
}When to reach for this:
import NetInfo from "@react-native-community/netinfo";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, FlatList, StyleSheet, Text, View } from "react-native";
type Message = { id: string; text: string; ts: number };
const WS_URL = "wss://echo.websocket.events"; // replace with your gateway
const POLL_MS_FOREGROUND = 15_000;
const POLL_MS_BACKGROUND = 120_000;
export function ChatScreen() {
const queryClient = useQueryClient();
const [messages, setMessages] = useState<Message[]>([]);
const [mode, setMode] = useState<"socket" | "poll">("socket");
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const appendMessage = useCallback(
(msg: Message) => {
setMessages((prev) => [...prev, msg]);
queryClient.setQueryData<Message[]>(["inbox"], (old = []) => [...old, msg]);
},
[queryClient]
);
// WebSocket path (foreground)
useEffect(() => {
if (mode !== "socket") return;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let disposed = false;
function connect() {
if (disposed || AppState.currentState !== "active") return;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
attempt = 0;
};
ws.onmessage = (e) => {
appendMessage({
id: String(Date.now()),
text: String(e.data),
ts: Date.now(),
});
};
ws.onclose = () => {
if (disposed) return;
const delay = Math.min(1000 * 2 ** attempt++, 20_000);
reconnectTimer = setTimeout(connect, delay);
};
}
connect();
return () => {
disposed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
ws?.close();
};
}, [mode, appendMessage]);
// Battery-aware fallback: polling when backgrounded or on cellular policy
useEffect(() => {
async function pickMode() {
const net = await NetInfo.fetch();
const background = AppState.currentState !== "active";
const expensive = net.type === "cellular";
if (background || expensive) {
setMode("poll");
} else {
setMode("socket");
}
}
void pickMode();
const appSub = AppState.addEventListener("change", () => void pickMode());
const netSub = NetInfo.addEventListener(() => void pickMode());
return () => {
appSub.remove();
netSub.remove();
};
}, []);
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
if (mode !== "poll") return;
const interval =
AppState.currentState === "active" ? POLL_MS_FOREGROUND : POLL_MS_BACKGROUND;
pollRef.current = setInterval(async () => {
// Replace with your REST delta endpoint
const res = await fetch("https://jsonplaceholder.typicode.com/comments?_limit=1");
if (!res.ok) return;
const data = await res.json();
appendMessage({
id: `poll-${Date.now()}`,
text: data[0]?.body ?? "poll tick",
ts: Date.now(),
});
}, interval);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [mode, appendMessage]);
return (
<View style={styles.screen}>
<Text style={styles.badge}>
Transport: {mode === "socket" ? "WebSocket" : "polling"} ({mode === "poll" ? "battery-aware" : "live"})
</Text>
<FlatList
data={messages}
keyExtractor={(m) => m.id}
renderItem={({ item }) => <Text style={styles.row}>{item.text}</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, backgroundColor: "#fff" },
badge: { fontSize: 12, color: "#64748b", marginBottom: 8 },
row: { paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#e2e8f0" },
});npx expo install @react-native-community/netinfo @tanstack/react-queryWhat this demonstrates:
setQueryData - one inbox source for list screens.| Phase | Action |
|---|---|
onclose / onerror | Close socket, schedule reconnect |
| Backoff | min(1000 * 2^attempt, 30s) + jitter |
| App background | Cancel reconnect timer; close socket |
| App foreground | Reset attempt counter optional; connect immediately |
| Auth expiry | Send refresh token over REST first - see Auth Session |
// Heartbeat - detect half-open connections
const ping = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 25_000);| State | Suggested interval | Transport |
|---|---|---|
| Foreground + Wi‑Fi | WebSocket or 10–15 s poll | Real-time |
| Foreground + cellular | WebSocket or 30 s poll | Product choice |
| Background | 60–300 s poll or none | Polling only if business requires |
| Offline | Stop both | Queue events locally |
| Technology | RN support | Notes |
|---|---|---|
| Native WebSocket | ✅ Built-in | Lowest overhead |
| Socket.io client | ✅ JS client | Extra protocol + bundle; match server |
SSE (EventSource) | ⚠️ Limited | Often polyfilled; WS is simpler on RN |
| Firebase / Supabase realtime | ✅ SDKs | Managed reconnect; vendor lock-in |
queryClient.setQueryData<Thread>(["thread", id], (old) => {
if (!old) return old;
return { ...old, messages: [...old.messages, incoming] };
});refetchQueries after onopen to backfill missed eventssince=cursor query params close the gap - document with backenduseState and Query cache - pick Query as source of truthbackground; poll or push instead.since cursor on onopen.message.id.| Alternative | Use When | Don't Use When |
|---|---|---|
| WebSocket + reconnect hook | Chat, live dashboards | Rare updates - polling is simpler |
| Long polling | Corporate proxies block WS | You need sub-second latency |
| FCM / APNs push | Background must-know events | In-app typing indicators |
| TanStack Query refetch interval | Low-frequency stock prices | High-frequency streams |
Yes - global WebSocket is available in Hermes. Use wss:// in production; avoid cleartext ws:// except local dev.
Often yes for foreground chat. For background or metered-only features, degrade to slower polling or push notifications.
Pass a short-lived token in query string (wss://api.example.com/ws?token=) or send an auth frame immediately after onopen. Refresh via REST before reconnect - Mobile Auth Basics.
Foreground: 15–30 s. Background: 2–5 min or stop entirely and rely on push. Measure battery with profiling tools on mid-tier Android.
Yes for wss endpoints. Production auth and custom native gateways need dev-client builds - same as REST.
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026