DEV Community

Cover image for Real-Time Location Tracking in React Native, Including Live Sharing
Hugo Rus
Hugo Rus

Posted on Originally published at rapidnative.com

Real-Time Location Tracking in React Native, Including Live Sharing

The moving dot on a delivery app looks trivial. Underneath it: a permission sequence that fails silently if you get the order wrong, a background task the OS actively wants to kill, a battery budget you have to defend, and a connection that has to survive a phone going into a pocket.

Most tutorials stop at watchPositionAsync on a demo screen. This goes through to live sharing between two devices.

  • Request foreground permission first, background second, and never on launch
  • Define the background task at module top level, not inside a component
  • Android needs a foreground service or the OS kills the task within minutes
  • Use broadcast for live position, not database inserts
  • Coarsest accuracy and longest interval that still feels responsive

The stack

npx expo install expo-location react-native-maps expo-task-manager
npm install @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

expo-location handles foreground and background GPS plus permissions. react-native-maps renders Apple Maps on iOS and Google Maps on Android. expo-task-manager is required for background location. Supabase provides the realtime channel for the sharing half.

Step 1: Permissions

This is where most of the bugs live.

{
  "expo": {
    "plugins": [
      [
        "expo-location",
        {
          "locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to share your live location.",
          "isIosBackgroundLocationEnabled": true,
          "isAndroidBackgroundLocationEnabled": true
        }
      ]
    ],
    "ios": { "infoPlist": { "UIBackgroundModes": ["location", "fetch"] } }
  }
}
Enter fullscreen mode Exit fullscreen mode

Request foreground first, background second. Always that order.

import * as Location from 'expo-location';

async function requestLocationPermissions() {
  const { status: fg } = await Location.requestForegroundPermissionsAsync();
  if (fg !== 'granted') throw new Error('Location required');

  const { status: bg } = await Location.requestBackgroundPermissionsAsync();
  if (bg !== 'granted') console.warn('Background disabled');
}
Enter fullscreen mode Exit fullscreen mode

And do not request background on app launch. Wait until the user taps something that needs it, like "start sharing". Asking for always-on location from a stranger is how you get a permanent denial on the first screen.

Step 2: Foreground tracking

import { useEffect, useState } from 'react';
import * as Location from 'expo-location';

export function useLiveLocation() {
  const [location, setLocation] = useState<Location.LocationObject | null>(null);

  useEffect(() => {
    let sub: Location.LocationSubscription | null = null;
    (async () => {
      sub = await Location.watchPositionAsync(
        {
          accuracy: Location.Accuracy.BestForNavigation,
          timeInterval: 2000,
          distanceInterval: 5,
        },
        setLocation,
      );
    })();
    return () => sub?.remove();
  }, []);

  return location;
}
Enter fullscreen mode Exit fullscreen mode

Three knobs:

  • accuracy. Balanced for delivery ETAs, BestForNavigation for anything where a few metres matters.
  • timeInterval. Minimum milliseconds between fixes.
  • distanceInterval. Minimum metres moved before firing. Set this to 5 or 10, otherwise a stationary user on a poor fix floods your UI with jitter.

That last one is the difference between a dot that sits still and a dot that vibrates.

Step 3: The map

import MapView, { Marker } from 'react-native-maps';

export function TrackingScreen() {
  const location = useLiveLocation();
  if (!location) return null;

  const region = {
    latitude: location.coords.latitude,
    longitude: location.coords.longitude,
    latitudeDelta: 0.005,
    longitudeDelta: 0.005,
  };

  return (
    <MapView style={{ flex: 1 }} region={region} showsUserLocation>
      <Marker coordinate={region} title="You are here" />
    </MapView>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use region, not initialRegion, if you want the map to follow. For a marker that slides between fixes rather than teleporting, animate the coordinate rather than setting it directly.

Step 4: Background tracking

Define the task at module top level. Not inside a component, not inside a hook. The OS invokes it before your React tree exists.

import * as TaskManager from 'expo-task-manager';
import * as Location from 'expo-location';

const LOCATION_TASK = 'background-location-task';

TaskManager.defineTask(LOCATION_TASK, ({ data, error }) => {
  if (error || !data) return;
  const { locations } = data as { locations: Location.LocationObject[] };
  void sendLocationsToServer(locations);
});

export async function startBackgroundTracking() {
  await Location.startLocationUpdatesAsync(LOCATION_TASK, {
    accuracy: Location.Accuracy.Balanced,
    timeInterval: 10_000,
    distanceInterval: 25,
    foregroundService: {
      notificationTitle: 'Sharing your location',
      notificationBody: 'Tap to open the app.',
    },
    pausesUpdatesAutomatically: true,
    activityType: Location.ActivityType.Fitness,
  });
}
Enter fullscreen mode Exit fullscreen mode

Two non-negotiables. On Android, foregroundService or the OS kills the task within minutes. On iOS, UIBackgroundModes: ["location"] or you get rejected at review.

Step 5: Live sharing

The part most tutorials skip.

A Supabase Realtime channel must be subscribed before it will send anything. Creating a channel and immediately calling send transmits nothing, and it fails quietly, which makes it a genuinely unpleasant afternoon.

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

// create and subscribe once, then reuse
export function createSession(sessionId: string) {
  const channel = supabase.channel(`session:${sessionId}`);
  channel.subscribe();
  return channel;
}

export function shareLocation(channel, loc: Location.LocationObject) {
  return channel.send({
    type: 'broadcast',
    event: 'position',
    payload: {
      lat: loc.coords.latitude,
      lng: loc.coords.longitude,
      heading: loc.coords.heading,
      at: Date.now(),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

On the observing device:

useEffect(() => {
  const channel = supabase
    .channel(`session:${sessionId}`)
    .on('broadcast', { event: 'position' }, ({ payload }) => {
      setPeerLocation({ latitude: payload.lat, longitude: payload.lng });
    })
    .subscribe();

  return () => { void channel.unsubscribe(); };
}, [sessionId]);
Enter fullscreen mode Exit fullscreen mode

Broadcast, not database inserts. Inserting every fix burns through row quota and forces observers to poll. If you also need history for a breadcrumb trail or route replay, write to a table on a much slower cadence in addition to the broadcast. Two different jobs, two different mechanisms.

Battery

Continuous high-accuracy GPS is one of the most expensive things an app can do, and the cost scales with accuracy and frequency in roughly the way you'd expect.

The ordering that matters:

Config Relative cost
BestForNavigation, 1 to 2s, foreground Highest
Balanced, 10s, background Moderate
Lowest, 30s, background Lowest

Absolute numbers depend heavily on device, OS version, and whether the screen is on, so measure on your own target hardware rather than trusting anyone's table, including this one.

The rule that generalises: use the coarsest accuracy and longest interval that still feels responsive, and set pausesUpdatesAutomatically: true, which lets iOS suspend tracking when the user has stopped moving. That last flag is close to free and it does more than most tuning.

Pitfalls

  • Blank map on Android. Missing android.config.googleMaps.apiKey in app config.
  • watchPositionAsync never fires in the simulator. The iOS simulator has no GPS. Set a custom location from the simulator's location menu.
  • Permission dialog on every launch. You're calling request* where you should call get* first and only request when the answer is undetermined.
  • Background works locally, dies in production. Missing UIBackgroundModes on iOS or foregroundService on Android. Both work fine in development, which is what makes this one expensive.

Skipping the plumbing

If you want the scaffold without the permission archaeology, RapidNative generates the Expo and Supabase project from a description, and the output is real React Native you can export and finish wherever you normally work.

The parts above still need understanding. Generated or not, the OS is going to kill your background task if the foreground service isn't configured, and no scaffold argues with that.


What's your background tracking horror story? Mine was a task that worked for three weeks and then stopped, because the notification the foreground service depends on had been silently disabled by the user.

Top comments (0)