Back to Blog
Mobile Development

React Native + WebSockets: Stop Killing Your Users' Battery Life

December 11, 20248 min readBy Mohammad Kanaan
Share:

You launch your on-demand service app. Users love the real-time tracking. Everything's perfect until you check the reviews:

"App drains my battery in 2 hours" ⭐ "Phone gets hot after 30 minutes" ⭐ "Had to uninstall, battery dying too fast" ⭐⭐

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):

TYPESCRIPT
// ❌ 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:

TYPESCRIPT
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;
  };
}
Battery savings: ~60%. Users can keep the app open all day without their phone dying.

Fix #2: Exponential Backoff on Reconnect
#

When connection drops (poor network, server restart), don't spam reconnect attempts:

TYPESCRIPT
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:

TYPESCRIPT
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]);
}
UI updates smoothly, battery life improves by another 20%, and users don't notice any lag.

Fix #4: Smart Backend Updates
#

Don't send location updates when the mechanic hasn't moved:

PHP
// 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:

TYPESCRIPT
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:

TYPESCRIPT
// 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:

MetricBeforeAfter
Battery drain (1 hour active tracking)18%7%
WebSocket reconnections~50/hour~2/hour
Network data usage12 MB/hour3 MB/hour
App store ratings3.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

Bonus: Add a "Low Battery Mode" that increases throttle to 10 seconds and reduces map animation when device battery is below 20%.

Tags

#React Native#WebSockets#Performance#Mobile#Real-time
MK

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.