Introduction
Building real-time features on mobile is fundamentally different from the web. WebSocket connections that feel stable on desktop browsers become fragile on phones, where the OS can suspend your app at any moment and the network can shift between Wi-Fi and cellular without warning. This guide walks through the practical realities of implementing real-time on mobile: a practical Kafka & WebSockets guide that actually survives the real world—battery constraints, background limitations, and network transitions included.
Background limits: iOS and Android suspend apps. Sockets don't survive in the background.
The first shock for developers accustomed to server-side or web development is that mobile operating systems actively work against long-lived connections. Both iOS and Android can suspend your app process within seconds of the user switching away. When suspension happens, your WebSocket is torn down, your event loop pauses, and any Kafka consumer you're running locally dies.
On iOS, the situation is particularly strict. An app backgrounded without special entitlements gets roughly 30 seconds before the system freezes its threads. Your socket read/write operations will fail, and you won't get exceptions in a timely way because the runtime itself is paused. Android is slightly more forgiving with background execution limits, but the principle remains: don't expect sustained connectivity when your app isn't in the foreground.
The architectural implication is clear: you cannot rely on a persistent WebSocket for real-time updates when the app is backgrounded. Instead, you need to architect for intermittent connectivity and accept that updates might arrive in bursts when the app returns to the foreground.
Consider a ride-hailing scenario. A driver might accept a ride, then switch to map navigation. The app is now backgrounded. A passenger sends a message to the driver. Your backend holds that message until the driver returns to the app, at which point you reconnect and deliver it. This is different from web, where the user's browser tab might stay active for hours.
The practical pattern is to design your mobile backend API to handle request-response query operations, not purely push-based streaming. When the app resumes, it fetches the latest state:
// On app resume (iOS: willEnterForeground, Android: onResume)
async function syncStateWhenResumed() {
try {
// Fetch latest messages, orders, location updates etc.
const updates = await fetch('/api/updates?since=' + lastSyncTime);
const data = await updates.json();
// Apply them locally
applyUpdates(data);
// Then reconnect WebSocket for live updates
if (!isSocketConnected()) {
connectWebSocket();
}
} catch (error) {
// Network might not be ready yet
console.error('Resume sync failed:', error);
}
}This two-tier approach—pull on resume, then push via socket—handles the background limitation gracefully. The pull ensures you never lose data even if the socket fails to reconnect immediately.
Push for wake-ups: Use push notifications when the app isn't active
Push notifications (APNs on iOS, FCM on Android) are the designated mechanism for waking an app when it's backgrounded. They bypass the suspension limitation by invoking a small amount of app code before the process is frozen again. This makes push the right tool for time-sensitive events that arrive while the user isn't actively using the app.
The pattern is to use your Kafka topics to trigger push notifications. When an event arrives that needs immediate user attention, your backend publishes to a push notification service, which delivers it to the device. The app receives the notification, either displays it to the user or silently syncs in the background (depending on notification type), then suspends again.
On iOS, you get roughly 30 seconds of background execution time after a silent notification arrives. On Android with high-priority FCM messages, you get similar latency. This is enough time to reconnect, fetch updated state, and update local caches—but not enough to stream large amounts of data.
Here's a practical flow for an e-commerce checkout scenario: a customer places an order, and the order enters Kafka. A processor consumes the order event and determines it needs shipping. Shipping assignment happens asynchronously. Once the order ships, the backend publishes a push notification to the customer's phone. The app wakes up, receives the push, makes a REST call to fetch shipping details, updates local state, and shows a notification. Total time: under 5 seconds. The customer's app feels real-time without needing a persistent socket.
// Backend: detect order shipped event from Kafka
orderTopic.on('message', async (msg) => {
const event = JSON.parse(msg.value);
if (event.type === 'ORDER_SHIPPED') {
// Send push notification
await pushService.send({
userId: event.customerId,
title: 'Your order shipped',
data: {
orderId: event.orderId,
trackingUrl: event.trackingUrl
},
// High priority means device wakes up
priority: 'high'
});
}
});On the mobile side, handle the push and use it as a signal to sync:
// iOS/Android native code (React Native shown)
import messaging from '@react-native-firebase/messaging';
messaging().onNotificationOpenedApp(async (remoteMessage) => {
// Sync the full order state
const order = await fetch(`/api/orders/${remoteMessage.data.orderId}`);
updateLocalOrder(await order.json());
});
// Also handle silent notifications (data-only)
messaging().onMessage(async (remoteMessage) => {
if (remoteMessage.data.type === 'ORDER_SHIPPED') {
const order = await fetch(`/api/orders/${remoteMessage.data.orderId}`);
updateLocalOrder(await order.json());
}
});The key insight: push notifications are your control plane. They wake the app and trigger syncs. WebSockets, when the app is active, provide the low-latency streaming. Together, they cover both foreground and background scenarios.
Network changes: Wi-Fi to cellular switches drop connections. Reconnect fast
Mobile devices constantly switch networks. A user might start on Wi-Fi at home, get in a car, and the phone automatically switches to cellular. These transitions almost always drop existing TCP connections. Your WebSocket will receive a connection reset, and you need to detect and reconnect within milliseconds to avoid gaps in real-time updates.
The problem is worse than it sounds because the device might not immediately notify your app that the network changed. On iOS, the Network framework provides callbacks, but on Android, you often need to actively poll or listen to system broadcasts. By the time your code notices, you might have tried to send data on a dead socket, experienced timeouts, and accumulated error state.
A robust approach uses a combination of heartbeat detection and platform-level network monitoring. Send a heartbeat every 30 seconds. If the heartbeat fails, disconnect and reconnect. Also listen for network change events and preemptively reconnect:
// React Native example
import { NetInfo } from '@react-native-community/netinfo';
let heartbeatTimer: NodeJS.Timeout;
let socket: WebSocket;
NetInfo.addEventListener(state => {
// Network state changed (Wi-Fi <-> Cellular)
console.log('Network changed:', state);
if (state.isConnected) {
// Network is now available, reconnect if needed
if (!socket || socket.readyState !== WebSocket.OPEN) {
reconnectWebSocket();
}
} else {
// Network is down, close socket to avoid hanging
if (socket) {
socket.close();
}
}
});
function sendHeartbeat() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'PING' }));
}
}
function startHeartbeat() {
heartbeatTimer = setInterval(sendHeartbeat, 30000);
}
function reconnectWebSocket() {
if (socket) {
socket.close();
}
socket = new WebSocket('wss://your-api.com/socket');
socket.onopen = () => {
console.log('Socket connected');
startHeartbeat();
// Replay any missed messages
syncMissedUpdates();
};
socket.onerror = (error) => {
console.error('Socket error:', error);
// Exponential backoff retry
setTimeout(reconnectWebSocket, Math.min(backoffMs, 30000));
};
}The detail to emphasize: network changes on mobile are frequent and inevitable. Your connection should be treated as transient by design. Store state locally so that reconnections don't lose context. Use sequence numbers or timestamps on messages so that when you reconnect, you can ask the server "what happened since I was last connected?" and fill in any gaps.
For a trading platform receiving real-time price updates, this means: if the socket drops, you keep the last known price locally, show it as "stale" to the user, and the moment you reconnect, you fetch the current price and any trades that occurred while you were offline. No data loss, transparent to the user.
Battery: Fewer, larger updates save battery
Every network operation on a mobile device wakes the radio, consuming precious battery. This is true whether it's HTTP, WebSocket, or push notifications. The mobile OS uses batching and coalescing to reduce wake cycles, but your app architecture can either cooperate or fight these optimizations.
The tension is real: real-time features feel best when updates arrive instantly, but instant updates on a mobile device drain battery visibly. Users will uninstall your app if it's a battery hog. You need to find a middle ground: deliver updates frequently enough to feel responsive, but batch them intelligently to minimize radio wake cycles.
One effective approach is to batch updates on the backend. Instead of pushing each price tick individually, accumulate price changes for 500 milliseconds, then send one message with all changes. For a user receiving hundreds of updates per minute, this reduces message volume by 50x without perceptibly delaying updates:
// Backend: batch price updates before broadcasting to clients
class PriceBatcher {
private batch: Map = new Map();
private flushTimer: NodeJS.Timeout;
constructor(private wsServer: WebSocketServer) {
this.flushTimer = setInterval(() => this.flush(), 500);
}
addUpdate(symbol: string, price: number, timestamp: number) {
this.batch.set(symbol, { symbol, price, timestamp });
}
private flush() {
if (this.batch.size === 0) return;
const updates = Array.from(this.batch.values());
this.batch.clear();
const message = JSON.stringify({ type: 'PRICE_BATCH', updates });
this.wsServer.broadcast(message);
}
}A second lever is adaptive update frequency. On poor connectivity (high latency, packet loss), back off. On strong connectivity, push more frequent updates. Also distinguish between user-critical events (a new message from a friend) and informational ones (a price moved 0.01%). Send critical events immediately; batch informational ones:
// Client: adaptive update handling
function handleUpdate(update: any) {
if (update.priority === 'critical') {
// Display immediately
displayNotification(update);
} else if (update.priority === 'normal') {
// Batch and display every 1-2 seconds
batchedUpdates.push(update);
if (!batchFlushScheduled) {
batchFlushScheduled = true;
setTimeout(() => {
applyBatchedUpdates();
batchFlushScheduled = false;
}, isHighLatency ? 2000 : 1000);
}
}
}For background syncs triggered by push notifications, use silent notifications sparingly and only for truly urgent data. For routine syncs, use the OS-provided background task APIs (WorkManager on Android, BackgroundTasks on iOS) which batch your work with other apps' background work, maximizing efficiency.
Common mistakes
- Assuming persistent sockets work for backgrounded apps. They don't. Design two-tier architectures with pull-on-resume and push for urgent events.
- Not handling network transitions gracefully. The socket will drop when the user moves from Wi-Fi to cellular. Expect it, detect it fast, reconnect with exponential backoff.
- Pushing too frequently. Hundreds of WebSocket messages per second feel real-time on desktop but drain battery on mobile. Batch at 500ms–1s intervals instead.
- Ignoring background execution time limits. You get ~30 seconds in the background on iOS. Don't try to stream or sync large datasets. Fetch what you need and return quickly.
- Using the same update strategy for all event types. A price tick is different from a new order notification. Prioritize critical events and batch informational ones separately.
Key takeaways
- Mobile background suspension kills persistent WebSockets. Design for intermittent connectivity with pull-on-resume and push-for-wake patterns.
- Network transitions (Wi-Fi to cellular) drop connections constantly. Implement heartbeats and network change listeners to detect and reconnect within milliseconds.
- Battery is the limiting resource on mobile. Batch updates into 500ms–1s windows and use priority levels to distinguish critical from informational events.
- Push notifications are your primary tool for reaching backgrounded apps. Use them to wake the device and trigger syncs, not to stream continuous data.
- Always query for state when the app resumes. Never assume your socket successfully delivered or received all updates while backgrounded.
- Implement exponential backoff for reconnection attempts. Give the network time to stabilize after a transition before hammering reconnection logic.






Comments
Loading comments…