expo-resilient-location
v0.1.0
Published
Background location for Expo that keeps reporting even when the app is force-quit — iOS Significant-Location-Change + Android foreground service. Transport-agnostic: you supply the upload endpoint and headers.
Maintainers
Readme
expo-resilient-location
Background location for Expo (Prebuild / Dev Client) that keeps reporting even after the app is force-quit — the gap expo-location does not cover on iOS.
- iOS — Significant-Location-Change API relaunches the app after termination (the only Apple-sanctioned mechanism). Once alive,
startUpdatingLocationlayers on for fine-grained foreground fixes. - Android — a
START_STICKYforeground service backed by Google's FusedLocationProvider survives app-kill. A boot receiver automatically restarts tracking after a reboot or app update. - Transport-agnostic — uploads batches to a URL you configure, with headers you set. Never imports your auth or domain logic. Arbitrary
extras(e.g.group_id) are merged into every uploaded point.
How tracking works
iOS
When start() is called the module registers two overlapping location modes:
| Mode | What it does | Survives force-quit? |
|---|---|---|
| startMonitoringSignificantLocationChanges | Wakes the app when the device moves ~500 m or switches cell towers (~15 min cadence). This is the relaunch engine. | Yes — the only Apple-approved way |
| startUpdatingLocation | High-accuracy continuous fixes while the app is foregrounded or backgrounded-but-alive. Auto-pauses when stationary. | No (foreground / alive only) |
On relaunch from a force-quit, the module re-arms SLC immediately in OnCreate (the kTracking flag in UserDefaults survives process death). Fixes are persisted to a UserDefaults-backed buffer so a background wake that ends abruptly never loses data.
iOS permission escalation: the OS requires NSLocationWhenInUseUsageDescription before you can even ask for Always. requestPermissions() triggers both prompts in sequence; the second (Always) is only shown by the OS after the first has been granted.
Android
start() persists config to SharedPreferences and brings up a foreground service:
- Foreground service (
ResilientLocationService) — registers withLocationServices.getFusedLocationProviderClientat 20 s intervals, high accuracy, no distance gate (so a stationary device still heartbeats). The OS mandates a visible persistent notification for background location; the module builds it fromandroidNotification.title/bodyyou supply — surface the trip name and end date there, it is a trust feature not friction. - Immediate fix — on service start,
getCurrentLocationfires a forced fresh fix (maxUpdateAgeMillis = 0). Without this, a stationary device with no cached location can wait a very long time (or forever) for the periodic stream to first emit. START_STICKY— if the OS kills the service under memory pressure, Android restarts it automatically, picking up the persisted config.BootReceiver— listens forBOOT_COMPLETEDandMY_PACKAGE_REPLACED, re-starts the service ifkTrackingis true andstopAfterhas not yet passed. Covers the "phone died mid-trip" case.
Buffering and uploads
Both platforms share the same invariants:
- On-device buffer capped at 1 000 points (oldest dropped when full).
- Uploads are chunked at 150 ticks per request — sending the whole buffer at once caused HTTP 500 once it exceeded the server's body-size limit; chunking is the fix.
- Each chunk is dropped from the front of the buffer only after a 2xx response, so a crash mid-drain or an offline period never re-sends or loses points.
- On failure (network error, 4xx, 5xx) the buffer is kept and retried on the next fix or the next explicit
flush()call. - Headers (e.g. a refreshed JWT) can be updated at any time via
setHeaders()without restarting tracking; the next upload attempt picks them up.
Good use cases
- Live group location sharing during a trip — one URL receives ticks from every member; your backend fans them out. The
extrasfield carries thegroup_idso you never need a separate handshake. - Travel apps that want a breadcrumb trail even when users background the app for hours or days.
- Field-service or delivery apps that need periodic location without a continuous GPS lock burning the battery.
- Any app that needs location to keep flowing across force-quit → reopen or reboot without requiring the user to manually restart sharing.
Exceptions and known limitations
| Situation | Effect |
|---|---|
| Aggressive OEM battery optimisers (Xiaomi/MIUI, Oppo/ColorOS, Vivo/OriginOS, some Samsung "Adaptive Battery" profiles) | The foreground service can be killed even with START_STICKY. The user must whitelist the app in the OEM battery settings. There is no SDK workaround. |
| Android battery-saver mode | The OS throttles or suspends background processes. The service may stop emitting until the user exits battery-saver. |
| iOS SLC coarseness | ~500 m movement or a cell-tower change, roughly every 15 minutes. Not suitable for street-level real-time tracking while the app is force-quit — only for "is the user still roughly here?" |
| iOS Always permission not granted | SLC requires Always. If the user grants only When In Use, the app will not be relaunched after a force-quit. requestPermissions() returns level: "whenInUse" so you can nudge the user to Settings. |
| iOS Background App Refresh disabled (per-app or system-wide) | SLC relaunches are suppressed. The module cannot detect or fix this — show a settings prompt. |
| Android ACCESS_BACKGROUND_LOCATION denied (Android 10+) | FusedLocation cannot collect while backgrounded. The permission must be requested in a separate step after ACCESS_FINE_LOCATION (Google Play requires a separate rationale screen). |
| stopAfter as the only stop mechanism | stopAfter is a local belt-and-braces guard for when the server's expired event never arrives. It should not be the primary stop signal — always call stop() from your server or app when the trip ends. |
| No geofences | The bg-fence source type is reserved in the types but not yet implemented. |
| Expo Go | Does not work — requires Prebuild + Dev Client. |
Install
# npm
npm install expo-resilient-location
# pnpm monorepo (workspace)
pnpm add expo-resilient-locationAdd the config plugin to your app.config.ts / app.json:
// app.config.ts
export default {
plugins: [
[
'expo-resilient-location',
{
// Optional — override the default iOS permission strings:
iosWhenInUsePermission:
'Show your location to your trip group while the app is open.',
iosAlwaysPermission:
'Share your live location with your trip group for the trip dates, even when the app is closed.',
},
],
],
};Then regenerate the native projects and build:
npx expo prebuild
npx expo run:ios # or run:androidRequires Expo SDK ≥ 53 and Dev Client — not Expo Go.
Android peer dependency
The Android side uses Google Play Services play-services-location. This is already a transitive dependency of most React Native apps; if yours does not have it, add it to android/build.gradle:
implementation 'com.google.android.gms:play-services-location:21.x.x'API
import * as ResilientLocation from 'expo-resilient-location';configure(opts: ConfigureOptions): void
Set the upload endpoint and headers. Call once at app start, before start(). Persisted natively so a background-relaunched process can still upload without JS being fully initialised.
ResilientLocation.configure({
url: 'https://api.example.com/trips/ticks',
headers: { Authorization: `Bearer ${token}` },
});setHeaders(headers: Record<string, string>): void
Update headers without restarting tracking. Call after refreshing an access token — the next upload attempt will use the new headers, and any buffered-but-unsent points will not be lost.
requestPermissions(): Promise<PermissionStatus>
Prompt for location permission. On iOS, requests WhenInUse first then escalates to Always (the OS enforces this two-step). Returns the current status immediately if a decision has already been made.
const status = await ResilientLocation.requestPermissions();
// { granted: true, level: 'always' | 'whenInUse' | 'denied', canAskAgain: boolean }
if (status.level !== 'always') {
// Nudge the user to Settings — SLC requires Always on iOS.
}start(options?: StartOptions): Promise<void>
Begin tracking. Idempotent — calling again replaces the options without creating a duplicate service.
await ResilientLocation.start({
extras: { group_id: trip.groupId }, // merged into every uploaded tick
stopAfter: trip.endsAt.toISOString(), // local self-stop guard (ISO-8601)
distanceFilterM: 25, // minimum metres before a new fix (default 25)
accuracyTier: 'high', // 'low' | 'balanced' | 'high' (default high)
androidNotification: {
title: `Sharing location — ${trip.name}`,
body: `Stops automatically on ${trip.endDateDisplay}`,
},
});androidNotification is required on Android — the OS mandates a visible persistent notification for any app collecting background location. Surface the trip name and end date: it is a transparency and trust feature, not friction.
stop(): Promise<void>
Stop tracking and tear down the background service / SLC monitoring.
flush(): Promise<void>
Force an immediate upload of any buffered points. Useful after calling setHeaders with a refreshed token to drain points that were blocked by a 401.
getStatus(): Promise<TrackingStatus>
const s = await ResilientLocation.getStatus();
// { tracking: boolean, permission: PermissionLevel, bufferedCount: number }addLocationListener(listener): EventSubscription
Receive fixes in real time while a JS listener is attached (e.g. to update a foreground map). Background fixes are uploaded natively and do not require a listener.
const sub = ResilientLocation.addLocationListener((fix) => {
// { lat, lng, accuracy_m, source, recorded_at }
updateMarker(fix);
});
// ...
sub.remove();addStatusListener(listener): EventSubscription
Notified whenever tracking or permission state changes.
Upload contract
POST <url>
Content-Type: application/json
<your headers>
{
"ticks": [
{
"lat": 15.3254,
"lng": 73.7614,
"accuracy_m": 12.4,
"source": "fg | bg-motion | bg-slc | bg-fence",
"recorded_at": "2026-05-21T08:14:33.012Z",
"group_id": "01HZ…" ← your extras, merged in
}
]
}source values:
| Value | Meaning |
|---|---|
| fg | Collected while the app was in the foreground (high accuracy, frequent) |
| bg-motion | Android FusedLocation periodic update while backgrounded |
| bg-slc | iOS Significant-Location-Change wake (coarse, ~500 m / ~15 min) |
| bg-fence | Reserved (geofence transition — not yet implemented) |
Your server should respond 2xx to acknowledge the batch. On 2xx the sent ticks are dropped from the on-device buffer. On any other response or network error the ticks are kept and retried — so a dead network or an expired token never permanently loses points.
Types
interface LocationFix {
lat: number;
lng: number;
accuracy_m: number | null;
source: 'fg' | 'bg-motion' | 'bg-slc' | 'bg-fence';
recorded_at: string; // ISO-8601
}
interface PermissionStatus {
granted: boolean;
level: 'denied' | 'whenInUse' | 'always';
canAskAgain: boolean; // false once permanently denied → send user to Settings
}
interface TrackingStatus {
tracking: boolean;
permission: 'denied' | 'whenInUse' | 'always';
bufferedCount: number;
}
interface StartOptions {
extras?: Record<string, string>;
stopAfter?: string | null; // ISO-8601
distanceFilterM?: number; // default 25
accuracyTier?: 'low' | 'balanced' | 'high';
androidNotification?: { title: string; body: string };
}
interface ConfigureOptions {
url: string;
headers?: Record<string, string>;
}License
MIT
