Workout Tracking in Mobile Apps: GPS, Heart Rate, BLE
When developing a workout tracker, the key challenges are GPS accuracy and background recording continuity. Even on flagship devices, raw GPS introduces noise of 5–15 meters, and the system might kill the app process within 15 seconds after minimizing. Without proper filtering, the track is full of zigzags, and data loss on crash means loss of user motivation. Additionally, on Android without a Foreground Service, the app can be killed at any moment; on iOS, even with "Always" permission, the system may pause updates when the battery is low. A particularly tough case is when an athlete runs through a forested area—GPS signal weakens and the track starts drifting without a Kalman filter.
We offer a turnkey tracking module that solves these problems: from data collection to integration with platform health stores. Our team with 5 years of mobile development experience guarantees stable recording on iOS and Android. In 3–5 weeks, you get a ready running tracker with GPS, distance, and pace. For more complex tasks—with heart rate, BLE sensors, and multiple activity types—the timeline is 2–4 months. We use proven algorithms and configurations that have shown high accuracy in over 15 commercial projects.
Let's dive into how we ensure track accuracy, background operation, and wearable device integration.
Session Architecture
The central element is the WorkoutSession (or however you name it), a state machine with states:
Idle → Preparing → Active → Paused → Active → Finishing → Saved
Transitions are triggered by the user (Start/Pause/Finish buttons) and the system (GPS loss, battery drain). All state is stored in a WorkoutRepository, persisted via Room (Android) or CoreData/SQLite (iOS) after each update—so that on crash, the session can be restored.
@Entity
data class WorkoutPoint(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: String,
val timestamp: Long,
val latitude: Double?,
val longitude: Double?,
val altitude: Double?,
val heartRate: Int?,
val speed: Double?,
val distance: Double
)
Every 5 seconds we insert a new record into the database. When the workout finishes, we aggregate everything into a WorkoutSummary. Intermediate points are not deleted—they're needed for track visualization.
How to Guarantee GPS Track Accuracy?
How to Filter GPS Noise?
Raw GPS data contains noise of ±5–15 m. On the route, this visually looks like zigzags instead of a straight line. We filter using the Kalman filter—it is more accurate than a moving average and adapts to movement dynamics. On straight segments, Kalman gives accuracy of ±2 m vs ±5 m for moving average; on turns, ±4 m vs ±10 m. In 80% of our projects, we use the Kalman filter—it improves accuracy by 60% on turns.
For mobile development, you don't need to implement Kalman from scratch. On Android, FusedLocationProviderClient already applies internal filtering. On iOS, CLLocationManager with kCLLocationAccuracyBestForNavigation uses sensor fusion. Additionally, we discard points with horizontalAccuracy > 20 m.
Steps to set up filtering:
- Initialize LocationManager with accuracy
kCLLocationAccuracyBestForNavigation(iOS) or create FusedLocationProviderClient (Android). - Set minimum distance update to 5 m.
- In the callback, discard points with
horizontalAccuracy > 20. - Apply Kalman filter to smooth remaining points.
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last,
location.horizontalAccuracy <= 20,
location.horizontalAccuracy >= 0 else { return }
if let previous = lastLocation {
let segment = location.distance(from: previous)
totalDistance += segment
}
lastLocation = location
trackPoints.append(location)
}
Distance and Pace Calculation
Distance is the sum of distances between consecutive GPS points (CLLocation.distance(from:) on iOS, Location.distanceTo() on Android). Pace (min/km) = 1000 / speed (m/s) / 60. Speed is taken from CLLocation.speed / Location.speed—they are calculated from Doppler shift, more accurate than coordinate differences.
When speed < 0 (no reliable data), we use speed calculated from coordinates.
Why Is Background Mode Important?
Without background operation, GPS turns off 15 seconds after minimizing the app. On iOS, we add UIBackgroundModes = location in Info.plist and request "Always" permission. On Android, we start a Foreground Service with a notification—otherwise, the system kills the process when memory is low.
class WorkoutTrackingService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildTrackingNotification()
startForeground(NOTIFICATION_ID, notification)
startLocationUpdates()
return START_STICKY
}
}
START_STICKY ensures the system restarts the service if it kills it, with a null intent. We handle null and restore state from Room.
Checklist for setting up background mode on iOS and Android
iOS:
- Add UIBackgroundModes = location to Info.plist
- Request always authorization (requestAlwaysAuthorization)
- Set allowsBackgroundLocationUpdates = true
- For session initiation, use CLMonitor (iOS 17+)
Android:
- Declare FOREGROUND_SERVICE_LOCATION permission (API 29+)
- Call startForeground with a notification channel
- Use START_STICKY for restart on kill
- Handle null Intent in onStartCommand
Comparison of GPS Filtering Methods
| Method | Accuracy on straight | Accuracy on turns | Computational complexity |
|---|---|---|---|
| Moving average | ±5 m | ±10 m | Low |
| Kalman filter | ±2 m | ±4 m | Medium |
| System FusedLocation | ±3 m | ±5 m | Low (built-in) |
Background Operation: iOS vs Android
| Platform | Mechanism | Requirements | Reliability |
|---|---|---|---|
| iOS | Background Location | Permission "Always", UIBackgroundModes | High, but may pause on low battery |
| Android | Foreground Service | Notification, START_STICKY | Very high, restarts on kill |
Integration with Wearable Devices
Heart rate from Apple Watch—via HKWorkoutBuilder on iOS (Watch automatically adds samples). On Android, Wear OS via HealthServicesClient or Bluetooth GATT with the Heart Rate profile (UUID 0x180D).
Bluetooth GATT for external sensors (chest strap Polar H10, Wahoo TICKR):
val hrServiceUUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val hrCharacteristicUUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
override fun onCharacteristicChanged(gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic) {
if (characteristic.uuid == hrCharacteristicUUID) {
val flag = characteristic.properties
val format = if (flag and 0x01 != 0) {
BluetoothGattCharacteristic.FORMAT_UINT16
} else {
BluetoothGattCharacteristic.FORMAT_UINT8
}
val heartRate = characteristic.getIntValue(format, 1) ?: 0
onHeartRateReceived(heartRate)
}
}
Saving to HealthKit / Health Connect
Upon workout completion, we write a full HKWorkout (iOS) or ExerciseSessionRecord (Android) with all nested metrics: distance, heart rate, route. On iOS, we use HKWorkoutRouteBuilder for the GPS track. The user should see the workout in the system Health app or Health Connect. Health Connect API is available on Android 10+.
What's Included
- Architecture documentation and session state diagrams
- Source code with comments and unit tests
- Integration with selected sensors (BLE, wearables)
- Configuration of background mode and notifications
- Deployment instructions for App Store / Google Play
- 2 months of post-delivery support
Timelines
Basic running tracker with GPS, distance, and pace: 3–5 weeks. Full tracker with heart rate, BLE sensors, multiple activity types, GPX export, and platform health store integration: 2–4 months.
Contact us to assess your project—we'll find the optimal solution for your stack and budget. Request custom workout tracking development and get a stable module with quality assurance.







