Skip to content

Tracking lifecycle

Once start() is called, the SDK is always in exactly one of two motion states — MOVING or STATIONARY — and almost everything else in the SDK (GPS power draw, fix density, which native services stay armed) is a consequence of that one bit. This is the core mental model for the whole tracker: most “why is battery draining” or “why did I get no points for 10 minutes” questions are really “which state was the device in, and what wakes it out of stationary.”

The split exists because full-rate GPS and battery life are directly opposed:

  • MOVING — GPS runs at full rate, spaced by the (speed-elastic) distanceFilter at desiredAccuracy. This is expensive but necessary: a route is only useful if it’s dense enough to reconstruct.
  • STATIONARY — GPS is, for practical purposes, asleep. A low-power keep-alive stream (see stationaryKeepAlive) and a stationary geofence around the last stopped fix are what’s left running. There is no reason to keep drawing moving-grade power for a device that hasn’t moved.

The state machine

stateDiagram-v2
    [*] --> STATIONARY
    STATIONARY --> MOVING: activity recognition
    STATIONARY --> MOVING: speed threshold
    STATIONARY --> MOVING: stationaryRadius exit
    STATIONARY --> MOVING: changePace(true)
    MOVING --> STATIONARY: still + stopTimeout → stop record

A tracking session starts in STATIONARY (there’s no fix yet to prove movement). Four independent signals can trigger STATIONARY → MOVING, and any one of them is sufficient:

  • Activity recognition says a moving-type activity (in_vehicle, on_bicycle, on_foot, running, walking by default — see triggerActivities) is happening, at or above the platform’s confidence bar.
  • Speed threshold — a raw fix implies real ground speed, independent of activity recognition. This is also the fallback path when activity recognition is disabled (disableMotionActivityUpdates) or has gone stale.
  • Stationary-geofence exit — a raw fix whose distance from the stop anchor clears stationaryRadius wakes tracking immediately, without waiting on activity recognition at all.
  • changePace(true) — an explicit app-driven override of the automatic machine.

The reverse transition, MOVING → STATIONARY, has exactly one path: a confident still verdict has to be sustained for stopTimeout before the machine commits. Committing writes a stop record (an isMoving: false onMotionChange event), re-arms the stationary geofence around the stop point, and drops GPS to the stationary power profile.

Stop detection

Getting to STATIONARY is deliberately harder than getting to MOVING, because a false stop costs GPS density on a genuinely still-moving route, while a missed stop only costs a few extra minutes of moving-power draw. The mechanics:

  • Countdown, not a snapshot. A confident still verdict arms a stopTimeout countdown rather than stopping immediately. If the countdown is already armed, a fresh still verdict does not restart it — only the original arm time matters, so the timer can’t be indefinitely postponed by repeated still updates.
  • Two vetoes can cancel a pending stop, both independent of activity recognition: a raw displacement of more than 50 m from the point where the countdown was armed cancels it outright (the device evidently never actually stopped), and — once the device is parked — a raw fix whose distance from the stop anchor clears stationaryRadius wakes tracking again. That second check runs ahead of the normal accuracy filter, because the stationary keep-alive stream is intentionally coarse.
  • Motion classification is tri-state, not boolean. Only a confident still verdict is allowed to arm a stop; low-confidence walking/unknown readings are treated as genuinely uncertain rather than rounded to “not moving.” A gap in raw fixes triggers recovery logic, never a stationary decision by itself — the machine never infers “stopped” from silence alone.

Wake sources

While STATIONARY, GPS is effectively asleep but the platform is not idle — a small set of services stays armed to detect departure, and to get the process back if iOS ends it.

SourceRole
liveUpdates auto-pause/resumeThe session stream is never stopped while tracking is enabled: it auto-pauses as the parked keep-alive and auto-resumes in the background the moment movement is detected. This is what removes the need for a foreground visit.
Wake regionA stationaryRadius-sized region parked at the stop anchor, and rolled along the route while moving. It survives a reboot in locationd, and a region exit is the one location event iOS delivers even to a force-quit app.
Significant-location-changeAlways registered; a coarse relaunch trigger after an OS-initiated termination.
Session recreation at relaunchThe session object is rebuilt synchronously at process launch, continuing the authorization grant it held before the process was evicted.
CLServiceSession (iOS 18+)A standing authorization-need declaration held for the stream’s lifetime, reinforcing grant preservation across suspension and relaunch. No-op below iOS 18.
Core MotionDrives the moving-activity trigger above; the machine falls back to speed and the wake region when it goes stale or is denied.

The session engine

On iOS 17+, useSessionEngine (default true) selects CLLocationUpdate.liveUpdates plus CLBackgroundActivitySession instead of the legacy CLLocationManager.startUpdatingLocation request, which iOS aggressively suspends between significant-location-change wakes.

That legacy behaviour is worth stating plainly, because it is the reason this path exists: on the old engine a backgrounded drive produced bursts of fixes separated by ~5-minute gaps — the app was suspended between SLC deliveries despite an active location request. Device traces showed CoreLocation delivering zero raw fixes during those gaps, so no amount of filter or upload tuning could have helped.

The deliberate cost is the blue indicator: a live session implies it, for as long as tracking is enabled. useSessionEngine: false is a remote-config kill-switch back to the legacy path, and devices below iOS 17 are on it regardless.

onMotionChange fires at every transition

onMotionChange is the one event that fires at both edges of the state machine — isMoving: true on STATIONARY → MOVING, isMoving: false on MOVING → STATIONARY (including changePace()-forced transitions). Watch for the very first motionchange of a tracking session: it fires from the initial moving-probe ahead of any fix, so location can be null — don’t assume it’s always populated.

Kill, force-quit, and reboot

The wake sources above double as the SDK’s resilience against the app being killed — including a user force-quit, which on iOS suppresses most background relaunch triggers. A live CLServiceSession plus synchronous session recreation at launch brings the process back in about a second; the rolling wake region is the belt-and-braces trigger underneath, and the only one on the pre-iOS-17 legacy path.

None of that needs a registration call from you. The engine installs its own launch observer, so a relaunched process has the engine running before your code does.

What survives which kill path, and how long a restart takes, is in Boot & killed-app behaviour.

Stationary heartbeat

While STATIONARY (or MOVING), a heartbeatInterval timer keeps firing regardless of motion state, carrying the last known location. It’s not a wake source by itself, but each firing also nudges the stop-timeout bookkeeping — see the heartbeat event for the payload shape.