A moving dot on a map looks simple. Behind it sit permission prompts, OS background rules, battery budgets, flaky mobile networks and a server that has to fan updates out to whoever is watching. Get any one of them wrong and the dot freezes, jumps, or quietly empties the phone by lunchtime.
Permissions come first, in two steps
Both platforms treat background location as a separate, more sensitive grant. Ask for foreground access when the user starts a feature that needs it, and only ask for background access once they have seen why. Asking for everything on first launch is the fastest way to a permanent "Deny".
import * as Location from 'expo-location';
export async function ensureLocationPermissions(needsBackground: boolean) {
const fg = await Location.requestForegroundPermissionsAsync();
if (fg.status !== 'granted') return 'denied';
if (!needsBackground) return 'foreground';
const bg = await Location.requestBackgroundPermissionsAsync();
return bg.status === 'granted' ? 'background' : 'foreground';
}Watching position in the foreground
For a screen that is open, watchPositionAsync is all you need. It takes an options object and a callback, and returns a subscription you must remove when the screen unmounts. The two options that matter most are timeInterval (minimum milliseconds between updates) and distanceInterval (minimum metres moved).
useEffect(() => {
let sub: Location.LocationSubscription | undefined;
(async () => {
sub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 3000,
distanceInterval: 10,
},
(loc) => {
const { latitude, longitude, speed, heading } = loc.coords;
queueUpdate({ latitude, longitude, speed, heading, t: loc.timestamp });
},
);
})();
return () => sub?.remove();
}, []);Keeping it running in the background
A watcher stops when the app is backgrounded. For delivery drivers or ride-hailing, you need a background task instead: define it with TaskManager.defineTask at module scope, then start it with startLocationUpdatesAsync. On Android this runs as a foreground service, which means a persistent notification the user can see. On iOS you need the "Always" permission and the location background mode enabled in your app config.
import * as TaskManager from 'expo-task-manager';
import * as Location from 'expo-location';
const TASK = 'driver-location';
TaskManager.defineTask(TASK, async ({ data, error }) => {
if (error) return;
const { locations } = data as { locations: Location.LocationObject[] };
await sendBatch(locations);
});
export function startBackgroundTracking() {
return Location.startLocationUpdatesAsync(TASK, {
accuracy: Location.Accuracy.Balanced,
timeInterval: 10000,
distanceInterval: 25,
foregroundService: {
notificationTitle: 'Trip in progress',
notificationBody: 'Sharing your location with the rider',
},
});
}Batching updates over WebSockets
Sending every fix the instant it arrives means one network write per update, and on a weak connection those writes pile up. A better pattern is a small in-memory queue flushed on a timer: collect points, send them as one message every couple of seconds, and drop points older than a few minutes if the socket has been down that long.
- —Keep one long-lived WebSocket per session rather than an HTTP request per fix.
- —Flush the queue every 2–5 seconds, or immediately when it grows past a size limit.
- —On reconnect, send the queued points in order and let the server discard anything it already has by timestamp.
- —Use exponential backoff with jitter for reconnects so a flaky tower does not trigger a reconnect storm.
- —On the server, broadcast only the latest position per driver to watchers; history belongs in storage, not in the live stream.
Smooth markers on the map
If you move the marker straight to each new coordinate, it teleports every few seconds. Watchers read that as lag. Instead, animate between the last known point and the new one over roughly the update interval, and rotate the marker toward the heading. When a gap is large (a tunnel, a lost connection), skip the animation and jump; interpolating across a kilometre just draws a car driving through buildings.
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export function interpolate(from: LatLng, to: LatLng, t: number): LatLng {
return {
latitude: lerp(from.latitude, to.latitude, t),
longitude: lerp(from.longitude, to.longitude, t),
};
}One more thing any location-based product has to plan for: users faking their position. That deserves its own treatment, covered in Detecting Fake GPS in React Native.
Key takeaways
- —Request foreground permission first and background permission only when a feature needs it.
- —Use
watchPositionAsyncfor open screens and always call.remove()on unmount. - —Background tracking needs a defined task, a foreground-service notification on Android and "Always" on iOS.
- —Tune
timeIntervalanddistanceIntervalfor battery; high accuracy is not free. - —Batch updates over a single WebSocket and reconnect with backoff.
- —Interpolate markers between fixes, but jump across large gaps.
Real-time tracking is mostly about restraint: fewer, well-timed updates, sent reliably and drawn smoothly, beat a firehose of raw GPS every time.
