Skip to content

Boot & killed-app behavior

BGeo’s contract is that tracking and upload are native, and Dart 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 Dart isolate 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 Dart isolate 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 — this is native engine behavior, identical to the same SDK’s React Native binding:

ScenarioAndroidiOS
App backgroundedForeground service keeps running; Dart isolate 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, Dart isolate 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). Dart 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 Dart isolate isn’t running (app killed, or not yet launched since boot), Android can still invoke a small piece of Dart through registerHeadlessTask(): native code (FlutterHeadlessDispatcher) spins up a background FlutterEngine, gives your task a short execution window, and tears it back down when the task’s Future completes.

Your task must be a top-level function or a static method — a closure or instance method can’t be resolved by PluginUtilities.getCallbackHandle in a fresh background isolate, and registerHeadlessTask() throws ArgumentError immediately if you pass one. Register it early — typically right alongside your ready() call — since the call to registerHeadlessTask() itself can happen anywhere in your app’s startup path; it’s the task function that has the top-level constraint, not the call site:

import 'package:bgeo_background_geolocation/bgeo_background_geolocation.dart'
hide State;
@pragma('vm:entry-point')
Future<void> headlessTask(HeadlessEvent event) async {
print('[bgeo headless] ${event.name} ${event.params}');
}
Future<void> main() async {
await BackgroundGeolocation.registerHeadlessTask(headlessTask);
runApp(const MyApp());
}

Event shape. A headless HeadlessEvent has a top-level name plus a params map holding every other payload field:

class HeadlessEvent {
final String name; // 'heartbeat' | 'motionchange' | 'geofence' | 'providerchange'
// | 'powersavechange' | 'http' | 'connectivitychange'
final Map<String, dynamic> params;
}

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 a background engine (and a wakelock) per fix while tracking, draining the battery and risking ANRs/foreground-service-start restrictions. Fixes taken while the Dart isolate isn’t running are still durably queued and uploaded by the native HTTP store; they just don’t reach a headless listener. 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 widget tree 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 equivalent of Android’s headless-dispatch mechanism, so registerHeadlessTask() retains your task on iOS but never invokes it while the app process isn’t running — Dart 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 Dart, so nothing at the OS layer is actually waiting on Dart to run.

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

  • Native uploads (queue drain, retry, poison-record handling, and authorization token refresh) all continue with no Dart isolate, 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 Dart listener.
  • Use logUrl for native-side diagnostic logging that needs to reach your server even when nothing Dart ever runs.