React Native + WebSockets: Stop Killing Your Users' Battery Life
You launch your on-demand service app. Users love the real-time tracking. Everything's perfect until you check the reviews:
I learned this the hard way building Sayartak (car maintenance app with 10K+ users). We were tracking mechanics in real-time. Users complained their phones were dying before lunch.
The culprit? Naive WebSocket implementation.
The Battery-Killing Mistake#
Here's what most devs do (including past me):
// ❌ Battery Destroyer 3000
import Echo from 'laravel-echo';
import Pusher from 'pusher-js/react-native';
const echo = new Echo({
broadcaster: 'pusher',
key: PUSHER_KEY,
cluster: 'ap2',
forceTLS: true,
});
// Subscribe and forget
echo.channel(`booking.${bookingId}`)
.listen('MechanicLocationUpdated', (data) => {
updateMarker(data.location);
});This code has 3 massive problems:
1. Keeps connection alive even when app is backgrounded
2. No reconnection backoff strategy
3. Updates at maximum frequency (every second in our case)
Fix #1: Intelligent Connection Management#
Only maintain WebSocket connection when the app is actually visible:
import { AppState } from 'react-native';
import { useEffect, useRef } from 'react';
export function useSmartWebSocket(bookingId: string) {
const echoRef = useRef<Echo | null>(null);
const appState = useRef(AppState.currentState);
useEffect(() => {
// Subscribe to app state changes
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
// App came to foreground
console.log('App active - connecting WebSocket');
connectWebSocket();
} else if (nextAppState.match(/inactive|background/)) {
// App went to background
console.log('App backgrounded - disconnecting WebSocket');
disconnectWebSocket();
}
appState.current = nextAppState;
});
// Initial connection
connectWebSocket();
return () => {
subscription.remove();
disconnectWebSocket();
};
}, [bookingId]);
const connectWebSocket = () => {
if (echoRef.current) return; // Already connected
echoRef.current = new Echo({
broadcaster: 'pusher',
key: PUSHER_KEY,
cluster: 'ap2',
forceTLS: true,
authEndpoint: `${API_URL}/broadcasting/auth`,
});
echoRef.current.channel(`booking.${bookingId}`)
.listen('MechanicLocationUpdated', handleLocationUpdate);
};
const disconnectWebSocket = () => {
if (!echoRef.current) return;
echoRef.current.disconnect();
echoRef.current = null;
};
}Fix #2: Exponential Backoff on Reconnect#
When connection drops (poor network, server restart), don't spam reconnect attempts:
class SmartWebSocketManager {
private retryCount = 0;
private maxRetries = 5;
private baseDelay = 1000; // 1 second
private echo: Echo | null = null;
connect() {
try {
this.echo = new Echo({
broadcaster: 'pusher',
key: PUSHER_KEY,
cluster: 'ap2',
forceTLS: true,
});
// Reset retry count on successful connection
this.echo.connector.pusher.connection.bind('connected', () => {
console.log('WebSocket connected');
this.retryCount = 0;
});
// Handle disconnections
this.echo.connector.pusher.connection.bind('disconnected', () => {
console.log('WebSocket disconnected');
this.handleReconnect();
});
// Handle errors
this.echo.connector.pusher.connection.bind('error', (error: any) => {
console.error('WebSocket error:', error);
this.handleReconnect();
});
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
this.handleReconnect();
}
}
private handleReconnect() {
if (this.retryCount >= this.maxRetries) {
console.log('Max retries reached. Giving up.');
// Show user a friendly message
Alert.alert(
'Connection Lost',
'Unable to connect to real-time updates. Pull to refresh to try again.'
);
return;
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = this.baseDelay * Math.pow(2, this.retryCount);
this.retryCount++;
console.log(`Reconnecting in ${delay}ms (attempt ${this.retryCount})`);
setTimeout(() => {
this.connect();
}, delay);
}
disconnect() {
if (this.echo) {
this.echo.disconnect();
this.echo = null;
}
this.retryCount = 0;
}
}Fix #3: Throttle Updates#
You don't need to update the UI every single time the backend sends data. Throttle it:
import { useRef, useCallback } from 'react';
function useThrottledUpdates<T>(callback: (data: T) => void, delay: number) {
const lastRun = useRef(Date.now());
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
return useCallback((data: T) => {
const now = Date.now();
const timeSinceLastRun = now - lastRun.current;
if (timeSinceLastRun >= delay) {
// Enough time has passed, run immediately
callback(data);
lastRun.current = now;
} else {
// Too soon, schedule for later
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(data);
lastRun.current = Date.now();
}, delay - timeSinceLastRun);
}
}, [callback, delay]);
}
// Usage
function TrackingScreen({ bookingId }: { bookingId: string }) {
const [mechanicLocation, setMechanicLocation] = useState(null);
// Only update map every 3 seconds max
const throttledUpdate = useThrottledUpdates(
(location) => setMechanicLocation(location),
3000
);
useEffect(() => {
echo.channel(`booking.${bookingId}`)
.listen('MechanicLocationUpdated', (data) => {
throttledUpdate(data.location);
});
}, [bookingId]);
}Fix #4: Smart Backend Updates#
Don't send location updates when the mechanic hasn't moved:
// Laravel Job - UpdateMechanicLocation.php
class UpdateMechanicLocation implements ShouldQueue
{
private const MIN_DISTANCE_METERS = 50; // Only update if moved 50m
public function handle()
{
$mechanic = Mechanic::find($this->mechanicId);
if (!$mechanic->hasActiveBooking()) {
// Don't broadcast if no active booking
return;
}
$newLocation = $this->getCurrentLocation();
$oldLocation = $mechanic->last_location;
// Calculate distance using Haversine formula
$distance = $this->calculateDistance(
$oldLocation['lat'],
$oldLocation['lng'],
$newLocation['lat'],
$newLocation['lng']
);
// Only broadcast if moved significantly
if ($distance > self::MIN_DISTANCE_METERS) {
broadcast(new MechanicLocationUpdated(
booking: $mechanic->activeBooking,
latitude: $newLocation['lat'],
longitude: $newLocation['lng']
));
$mechanic->update(['last_location' => $newLocation]);
}
}
private function calculateDistance($lat1, $lng1, $lat2, $lng2): float
{
$earthRadius = 6371000; // meters
$latFrom = deg2rad($lat1);
$lonFrom = deg2rad($lng1);
$latTo = deg2rad($lat2);
$lonTo = deg2rad($lng2);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$a = sin($latDelta / 2) * sin($latDelta / 2) +
cos($latFrom) * cos($latTo) *
sin($lonDelta / 2) * sin($lonDelta / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
}Fix #5: Use Network State#
Don't waste battery trying to connect when there's no internet:
import NetInfo from '@react-native-community/netinfo';
function useNetworkAwareWebSocket(bookingId: string) {
const [isConnected, setIsConnected] = useState(true);
const echoRef = useRef<Echo | null>(null);
useEffect(() => {
// Listen to network changes
const unsubscribe = NetInfo.addEventListener(state => {
const connected = state.isConnected && state.isInternetReachable;
if (connected && !isConnected) {
// Network restored
console.log('Network restored - reconnecting');
connectWebSocket();
} else if (!connected && isConnected) {
// Network lost
console.log('Network lost - disconnecting');
disconnectWebSocket();
}
setIsConnected(connected);
});
return () => unsubscribe();
}, []);
// ... connect/disconnect logic
}The Complete Solution#
Putting it all together in a reusable hook:
// hooks/useRealtimeTracking.ts
import { useEffect, useRef, useState } from 'react';
import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js/react-native';
interface Location {
latitude: number;
longitude: number;
}
export function useRealtimeTracking(bookingId: string) {
const [location, setLocation] = useState<Location | null>(null);
const [isConnected, setIsConnected] = useState(false);
const echoRef = useRef<Echo | null>(null);
const appState = useRef(AppState.currentState);
const lastUpdate = useRef(Date.now());
const retryCount = useRef(0);
useEffect(() => {
// App state listener
const appStateSubscription = AppState.addEventListener('change', (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
connect();
} else if (nextAppState.match(/inactive|background/)) {
disconnect();
}
appState.current = nextAppState;
});
// Network listener
const netInfoUnsubscribe = NetInfo.addEventListener(state => {
const connected = state.isConnected && state.isInternetReachable;
if (connected && !isConnected) {
connect();
} else if (!connected && isConnected) {
disconnect();
}
setIsConnected(connected);
});
connect();
return () => {
appStateSubscription.remove();
netInfoUnsubscribe();
disconnect();
};
}, [bookingId]);
const connect = () => {
if (echoRef.current || appState.current.match(/inactive|background/)) {
return;
}
try {
echoRef.current = new Echo({
broadcaster: 'pusher',
key: process.env.EXPO_PUBLIC_PUSHER_KEY,
cluster: 'ap2',
forceTLS: true,
authEndpoint: `${process.env.EXPO_PUBLIC_API_URL}/broadcasting/auth`,
});
echoRef.current.channel(`booking.${bookingId}`)
.listen('MechanicLocationUpdated', handleLocationUpdate);
echoRef.current.connector.pusher.connection.bind('connected', () => {
setIsConnected(true);
retryCount.current = 0;
});
echoRef.current.connector.pusher.connection.bind('disconnected', () => {
setIsConnected(false);
handleReconnect();
});
} catch (error) {
console.error('WebSocket connection failed:', error);
handleReconnect();
}
};
const disconnect = () => {
if (echoRef.current) {
echoRef.current.disconnect();
echoRef.current = null;
setIsConnected(false);
}
};
const handleLocationUpdate = (data: { latitude: number; longitude: number }) => {
const now = Date.now();
// Throttle to max 1 update per 3 seconds
if (now - lastUpdate.current < 3000) {
return;
}
lastUpdate.current = now;
setLocation(data);
};
const handleReconnect = () => {
if (retryCount.current >= 5) return;
const delay = 1000 * Math.pow(2, retryCount.current);
retryCount.current++;
setTimeout(() => {
disconnect();
connect();
}, delay);
};
return { location, isConnected };
}The Results#
After implementing these optimizations:
| Metric | Before | After |
|---|---|---|
| Battery drain (1 hour active tracking) | 18% | 7% |
| WebSocket reconnections | ~50/hour | ~2/hour |
| Network data usage | 12 MB/hour | 3 MB/hour |
| App store ratings | 3.2⭐ | 4.6⭐ |
Key Takeaways#
1. Disconnect when backgrounded - Saves 60% battery
2. Exponential backoff on reconnect - Don't spam the server
3. Throttle UI updates - 3 seconds is fine for most use cases
4. Only send meaningful updates - Backend should filter insignificant changes
5. Respect network state - Don't waste battery on offline connections
Tags
Mohammad Kanaan
Senior Full-Stack Engineer specializing in Next.js, React, Laravel, and scalable web architectures. Building production-grade applications with modern tech stacks.
Enjoyed this article?
Get notified when I publish new content about web development, architecture patterns, and tech insights.
No spam, ever. Unsubscribe at any time.