Skip to content

Boot & killed-app behavior

BGeo’s contract is that tracking and upload are native, and JS is optional at runtime. Once ready() has started tracking, the foreground service (Android) or location engine (iOS) keeps recording, filtering, persisting to SQLite, and uploading with no JS context required. Your onLocation/onMotionChange/etc. listeners are UI observers on top of that pipeline, not part of it — see the Tracking lifecycle concept for the one-paragraph summary this guide expands into a full matrix.

“Killed” isn’t one state. This guide distinguishes five, because each has a different platform story:

  • Backgrounded — the app is merely not in the foreground. Both platforms keep the JS context alive; nothing here is really about killing at all.
  • Swiped away / task removed — the user removes the app from the Android recent-apps list. iOS has no equivalent gesture that’s distinct from a force-quit.
  • Force-quit (iOS meaning) — the user swipes the app away in iOS’s app-switcher, which is Apple’s explicit signal to stop the process and (on older iOS behavior) suppress most background relaunch triggers.
  • OS-killed (memory eviction) — the OS terminates the process itself to reclaim memory, with no user action. The most common real-world case is an overnight eviction of a backgrounded app.
  • Device reboot — the whole OS restarts; every process is gone and every in-memory registration is gone with it, though some platform registrations (Android’s boot broadcast, iOS’s locationd state) persist across the reboot itself.

The matrix

The verified end-state (device-tested, see the sourcing note in each row) for what keeps tracking alive, what relaunches the process, and the expected tracking gap:

ScenarioAndroidiOS
App backgroundedForeground service keeps running; JS context alive. No gap.Session engine (useSessionEngine, default on iOS 17+) keeps a live location stream that auto-pauses at the parked keep-alive and auto-resumes on movement — no relaunch needed, JS context alive. No gap.
Swiped away (task removed)onTaskRemoved in the foreground service immediately restarts it (START_STICKY) — tracking continues without ever fully stopping. No gap.No distinct gesture — the app-switcher swipe is iOS’s force-quit; see the next row.
Force-quit (iOS meaning)N/A — Android has no force-quit concept distinct from task removal, handled above.The session engine relaunches the process and recreates its session synchronously at launch (recreateSessionStreamOnRelaunch + CLServiceSession, iOS 18+), continuing the authorization grant held before the kill. Device-verified across three force-quit deaths: relaunch in ~1 second, roughly a second of tracking loss. The rolling wake region (armed in both motion states, re-centered on enterMoving) is a secondary relaunch trigger and the only one on the pre-iOS-17 legacy path, which has no session to recreate — that path relaunches on a region exit, roughly 1–2× stationaryRadius of silence.
OS-killed (memory eviction)Foreground service is designed not to be evicted under normal conditions; if the process still dies, onTaskRemoved/service restart semantics apply the same as above.Same session-recreation-at-relaunch path as force-quit — an OS-initiated termination is not distinguished from a user force-quit by the engine. The overnight-eviction case (backgrounded app, process dies while the phone sleeps) is closed by the same mechanism: the session is recreated synchronously in init, and liveUpdates auto-resume relaunches the app in the background the moment movement resumes, with no foreground visit required.
Device rebootBootReceiver restarts the service on BOOT_COMPLETED/LOCKED_BOOT_COMPLETED when tracking was enabled and startOnBoot is true. With false, nothing survives the reboot (geofences, activity-recognition PendingIntents, the foreground service all die with the process) until the app is next opened.No boot broadcast exists, so startOnBoot:true is best-effort by design: the wake-region and significant-location-change registrations persist in locationd across the reboot and relaunch the app in the background once movement or a region event triggers them — device-verified relaunch in roughly 1 minute after boot, parked, no movement required, with the wake region confirmed still armed in locationd post-reboot. false is honoured via kern.boottime reboot detection: init compares the persisted boot time against the current one (tolerant of clock drift, not systemUptime, which pauses during device sleep) and drops the persisted enabled flag before any auto-resume path runs, so relaunch finds tracking disabled and does nothing.

stopOnTerminate & startOnBoot

These two config keys govern the two sides of app-kill behavior — whether tracking survives a kill, and whether it survives a reboot — and both are honoured on both platforms, but through different mechanisms because iOS has no reliable terminate or boot hook.

stopOnTerminate (default false): whether tracking should stop when the app process is killed.

  • Android tears tracking down directly, synchronously, in the foreground service’s onTaskRemoved.
  • iOS has no terminate hook to run code in, so the flag is honoured at next relaunch instead: init drops the persisted enabled flag before any auto-resume path runs (both recreateSessionStreamOnRelaunch and the deferred startTracking are gated on it). JS sees state.enabled === false on the next ready()/app open and has to call start() again — there’s a short window between the actual kill and the next relaunch where, from iOS’s perspective, tracking hasn’t stopped yet (the process is simply gone), but no further fixes are produced because the process isn’t running either way.

startOnBoot (default false): whether tracking should resume automatically after a reboot, if it was enabled when the device went down.

  • Android’s implementation matches its name literally: BootReceiver restarts the service on BOOT_COMPLETED/LOCKED_BOOT_COMPLETED when both isEnabled() and this flag are true.
  • iOS has no boot broadcast, so true is best-effort: the wake-region and SLC registrations that survive the reboot in locationd are what relaunch the app, not a dedicated boot handler. false is still honoured — reboot detection via kern.boottime drops the persisted enabled flag on the first relaunch after a reboot, so nothing auto-resumes.

Android headless tasks

While the JS context isn’t running (app killed, or not yet launched since boot), Android can still invoke a small piece of JS through registerHeadlessTask(): the OS spins up a HeadlessJsTaskService, gives your task a short execution window, and tears the JS context back down when it returns.

Register it outside any component — in index.js, alongside your AppRegistry.registerComponent() call, so it’s wired up before any screen ever mounts:

index.js
import { AppRegistry } from 'react-native';
import BackgroundGeolocation from '@dc-bgeo/react-native-background-geolocation';
import App from './App';
BackgroundGeolocation.registerHeadlessTask(async (event) => {
switch (event.name) {
case 'heartbeat':
console.log('[headless] heartbeat', event.location);
break;
case 'motionchange':
console.log('[headless] motionchange', event.isMoving);
break;
case 'geofence':
console.log('[headless] geofence', event.identifier, event.action);
break;
default:
console.log('[headless] event', event.name, event);
}
});
AppRegistry.registerComponent('YourAppName', () => App);

Event shape. A headless HeadlessEvent has no params wrapper — the event’s payload fields are flattened directly alongside a top-level name:

interface HeadlessEvent {
name: 'heartbeat' | 'motionchange' | 'geofence' | 'providerchange'
| 'powersavechange' | 'http' | 'connectivitychange';
[key: string]: any; // payload fields, flattened
}

Runtime event set. Seven event types reach the headless task: heartbeat, motionchange, geofence, providerchange, powersavechange, http, and connectivitychange. location is deliberately excluded — forwarding every accepted fix headlessly would spin up HeadlessJsTaskService (and a wakelock) per fix while tracking, draining the battery and risking ANRs/foreground-service-start restrictions. Fixes taken while the JS context isn’t running are still durably queued and uploaded by the native HTTP store; they just don’t reach a headless listener. HeadlessEvent['name'] lists exactly these seven names, so narrowing on event.name is exhaustive. See the per-event Headless line on the Events reference for which events carry which fields headlessly.

Constraints. A headless task runs on a strict execution budget: keep it fast, do no UI work (there’s no mounted component to update), and prefer await-able async work — anything long-running risks the OS killing the task before it completes.

iOS: no headless

iOS has no AppRegistry-driven headless task service, so registerHeadlessTask() retains your task on iOS but never invokes it while the app process isn’t running — JS simply does not run for a killed iOS app, headless or otherwise. This is by design, not a gap to work around: the native engine already keeps recording, filtering, persisting, and uploading independently of JS, so nothing at the OS layer is actually waiting on JS to run.

The design answer for “I need this to survive a kill” is the same on both platforms — do it natively, not in JS:

  • Native uploads (queue drain, retry, poison-record handling, and authorization token refresh) all continue with no JS context, on both platforms. See Authorization (JWT refresh).
  • Attach anything the server needs at ingest time via url request shaping (params, extras) rather than deriving it in a JS listener.
  • Use logUrl for native-side diagnostic logging that needs to reach your server even when nothing JS ever runs.