Push notifications vs sockets on mobile: choosing the right channel
Every mobile app needs to deliver real-time updates, but the moment your user puts the phone down or switches to another app, your WebSocket connection drops. This is where most teams get confused: they try to force one tool to do both jobs. In this article, we'll cut through the noise and show you exactly when to use sockets, when to use push notifications, and how to sync them together so your users never miss critical data.
Why mobile changes the rules
On the web, a user keeps a tab open and a persistent connection alive. Mobile is different. The OS suspends your app, kills background processes, and yes—disconnects your WebSocket. You can't prevent this. Apple and Android designed it that way to preserve battery life and security. Trying to fight the OS is a losing battle.
The solution isn't to pick one channel. It's to use each for what it's designed to do:
- WebSockets deliver instant updates while the app is active and the user is watching.
- Push notifications wake the app (or alert the user) when important events happen in the background.
- Sync logic fills in the gaps when the app resumes after backgrounding.
Sockets while the app is open
WebSockets shine when the user is actively using your app. The connection is alive, latency is low, and you can push updates immediately. This is perfect for collaborative features, real-time dashboards, live feeds, and anything where the user expects instant feedback.
On a ride-hailing platform, for example, when a driver accepts a ride request, the passenger sees the driver's location update every 2–3 seconds via WebSocket. That's smooth, responsive, and what users expect. The driver app maintains a persistent connection to your Kafka topic stream (or direct WebSocket server), and every location update flows through.
Here's a simplified example using a NestJS gateway and React Native:
// NestJS WebSocket Gateway
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ namespace: 'rides' })
export class RidesGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private userSockets = new Map<string, Socket>();
handleConnection(socket: Socket) {
const userId = socket.handshake.auth.userId;
this.userSockets.set(userId, socket);
console.log(`User ${userId} connected`);
}
handleDisconnect(socket: Socket) {
const userId = socket.handshake.auth.userId;
this.userSockets.delete(userId);
console.log(`User ${userId} disconnected`);
}
@SubscribeMessage('subscribe_ride')
handleSubscribe(socket: Socket, data: { rideId: string }) {
socket.join(`ride:${data.rideId}`);
return { status: 'subscribed' };
}
// Called from Kafka consumer when driver location updates
broadcastDriverLocation(rideId: string, location: { lat: number; lng: number }) {
this.server
.to(`ride:${rideId}`)
.emit('driver_location_update', {
lat: location.lat,
lng: location.lng,
timestamp: Date.now(),
});
}
}
On the React Native side, connect on app launch and disconnect (or at least stop listening) when backgrounding:
import io, { Socket } from 'socket.io-client';
import { AppState, AppStateStatus } from 'react-native';
export const useRideSocket = (rideId: string, userId: string) => {
const [location, setLocation] = useState(null);
const socketRef = useRef<Socket | null>(null);
const appState = useRef(AppState.currentState);
useEffect(() => {
// Connect only on mount
socketRef.current = io('https://your-api.com', {
auth: { userId },
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socketRef.current.on('connect', () => {
socketRef.current?.emit('subscribe_ride', { rideId });
});
socketRef.current.on('driver_location_update', (data) => {
setLocation(data);
});
const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
appState.current = state;
if (state === 'background') {
console.log('App backgrounded, socket will drop');
}
if (state === 'active') {
console.log('App resumed, will resync');
}
});
return () => {
subscription.remove();
socketRef.current?.disconnect();
};
}, [rideId, userId]);
return location;
};
The key insight: use sockets for high-frequency, low-stakes updates. Location pings, chat messages, cursor positions, stock ticks. These create a sense of responsiveness. If one ping is lost, the next one arrives in milliseconds anyway.
Push when it's backgrounded
When your app goes to the background, the WebSocket disconnects. If something urgent happens—a payment processed, an order shipped, a critical alert triggered—you need push notifications. They work because they're delivered via the OS push service (APNs on iOS, FCM on Android), not your WebSocket.
Push notifications are asynchronous, decoupled from your app's connection state, and they work even if your app hasn't been opened in days. The tradeoff: they're slower (seconds to tens of seconds, not milliseconds), and there's no guarantee of delivery. Google and Apple have rate limits, and users can disable them. Never rely on push alone for critical state changes.
For a trading platform, imagine a user sets a price alert. While the app is backgrounded, the alert triggers. A push notification arrives: "BTC hit $42,000." The notification is nice-to-have, but the real state (the alert triggered, the price recorded) lives in your database. If the user ignores the notification, they'll still see it when they open the app and sync.
Here's a typical Kafka-to-push flow:
// Kafka consumer listening for alert events
import { Kafka, Consumer } from 'kafkajs';
import * as admin from 'firebase-admin';
const kafka = new Kafka({ brokers: ['localhost:9092'] });
const consumer = kafka.consumer({ groupId: 'push-notification-group' });
admin.initializeApp();
const messaging = admin.messaging();
const runConsumer = async () => {
await consumer.connect();
await consumer.subscribe({ topic: 'price-alerts' });
await consumer.run({
eachMessage: async ({ topic, partition, message }) => {
const alert = JSON.parse(message.value.toString());
// Get user's FCM token from database
const user = await getUserById(alert.userId);
if (!user.fcmToken) return;
// Send push notification
try {
await messaging.send({
token: user.fcmToken,
notification: {
title: `Price Alert: ${alert.symbol}`,
body: `${alert.symbol} reached ${alert.price}`,
},
data: {
alertId: alert.id.toString(),
symbol: alert.symbol,
price: alert.price.toString(),
action: 'navigate_to_alerts',
},
android: {
priority: 'high',
ttl: 3600, // 1 hour
},
apns: {
headers: {
'apns-priority': '10',
},
payload: {
aps: {
sound: 'default',
badge: 1,
},
},
},
});
console.log(`Push sent to user ${alert.userId}`);
} catch (error) {
console.error(`Failed to send push for alert ${alert.id}:`, error);
}
},
});
};
runConsumer();
On the React Native side, handle the notification when it arrives or when the user taps it:
import messaging from '@react-native-firebase/messaging';
// Handle notification when app is backgrounded or closed
messaging().onNotificationOpenedApp((remoteMessage) => {
if (remoteMessage?.data?.action === 'navigate_to_alerts') {
// Navigate to alerts screen
navigationRef.navigate('Alerts', {
alertId: remoteMessage.data.alertId,
});
}
});
// Handle notification when app is in foreground
messaging().onMessage(async (remoteMessage) => {
console.log('Notification received while app is in foreground', remoteMessage);
// You can show an in-app alert or toast here
});
The pattern: Kafka topic emits events → background consumer listens → consumer fetches user FCM tokens and device tokens → sends push via FCM/APNs. The push wakes the user or alerts the app. The actual state is already in your database.
Sync missed data on resume
Here's the problem: your app was backgrounded for 30 minutes. Three important messages came through. Push notifications were sent and displayed, but what if the user swiped them away? What if their FCM token expired? What if they never saw the notification?
When the app resumes and the WebSocket reconnects, you need to fetch any state that changed while you were gone. This is your sync window. It's not glamorous, but it's critical.
The simplest approach: every time the app comes to the foreground, fetch a delta of changes since the last sync timestamp.
// React Native sync logic
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const useSyncOnResume = () => {
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription.remove();
}, []);
const handleAppStateChange = async (state) => {
if (state === 'active') {
// App just came to foreground
const lastSyncTime = await AsyncStorage.getItem('lastSyncTime');
const sinceTimestamp = lastSyncTime ? parseInt(lastSyncTime) : Date.now() - 60 * 60 * 1000; // 1 hour fallback
try {
// Fetch all changes since last sync
const response = await fetch('https://your-api.com/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ since: sinceTimestamp }),
});
const updates = await response.json();
// Apply updates to local state/database
for (const update of updates.messages) {
await applyMessageUpdate(update);
}
for (const update of updates.orders) {
await applyOrderUpdate(update);
}
// Update last sync timestamp
await AsyncStorage.setItem('lastSyncTime', Date.now().toString());
} catch (error) {
console.error('Sync failed:', error);
// Retry on next resume
}
}
appState.current = state;
};
};
On the backend, the sync endpoint returns all changes (messages, orders, alerts, whatever) that occurred after the provided timestamp:
// NestJS sync endpoint
@Post('/sync')
@UseGuards(AuthGuard)
async sync(@Req() req, @Body() body: { since: number }) {
const userId = req.user.id;
const sinceTimestamp = body.since;
const [messages, orders, alerts] = await Promise.all([
this.messageService.findNewSince(userId, sinceTimestamp),
this.orderService.findNewSince(userId, sinceTimestamp),
this.alertService.findNewSince(userId, sinceTimestamp),
]);
return {
messages,
orders,
alerts,
syncedAt: Date.now(),
};
}
This fills the gap. The user opens the app, the sync endpoint returns all missed updates, the local state is reconciled, and the WebSocket resumes real-time streaming. No information is lost.
Never rely on push for critical state
This cannot be overstated: push notifications are not guaranteed delivery. They're best-effort. A notification can fail to send because:
- The FCM token is invalid or expired.
- The user revoked permissions.
- The device is offline or in do-not-disturb mode.
- Google or Apple dropped the message due to rate limits.
- The user swiped the notification away without opening it.
If your business logic depends on the user seeing the push notification, you've built a fragile system. Instead, push notifications should be a notification channel, not a data channel. The real state lives in your database. The push is a courtesy to alert the user.
Wrong approach:
Send a push when a payment is processed. Assume the user saw it. Close the order if they don't reply within 5 minutes.
Right approach:
Payment is processed and persisted in the database with status "completed." A push notification alerts the user. When the app opens, it syncs and fetches the real order state from the database. The order is closed only after server-side validation, not based on push delivery.
For a SaaS CRM, when a user's trial is about to expire, you send a push notification. But you don't cancel their account if they don't tap it. The expiration is handled server-side, and the app checks the real trial status when it syncs. The push is just a heads-up.
Common mistakes
Mistake 1: Trying to use sockets for background updates. You can't keep a WebSocket alive indefinitely in the background. The OS will kill it. Don't fight it; use push instead.
Mistake 2: Sending critical state updates via push only. If a payment or order status hinges on push delivery, you've built a time bomb. Always store state server-side and sync on resume.
Mistake 3: Not cleaning up socket connections on background. Leaving sockets open wastes battery and can cause unexpected behavior. Explicitly close or pause listeners when the app backgrounded.
Mistake 4: Forgetting to handle push when the app is killed. If the user force-quits your app, the notification might still be tappable. Make sure your app can handle a deep link from a cold start and sync the relevant state.
Mistake 5: Syncing too much data. If you sync the entire user state every time the app resumes, you'll overwhelm the network and the database. Always sync by timestamp or a change log. Paginate if needed.
Key takeaways
- Use WebSockets for real-time updates while the app is open. Low latency, high frequency, perfect for dashboards and live feeds.
- Use push notifications when the app is backgrounded. Decoupled from connection state, works even if the app is closed.
- Always store critical state server-side. Never rely on push delivery to confirm state changes.
- Sync on app resume. Fetch all changes since the last sync timestamp to fill the gap while the app was backgrounded.
- Design for the OS, not against it. Accept that WebSockets will disconnect. Plan for graceful degradation.
- Push notifications should be a notification channel, not a data channel. They're nice-to-have, not required.
- Test both connected and disconnected flows. Many bugs appear only when the network is flaky or the device backgrounded.






Comments
Loading comments…