# How to Create a Background GPS Tracker in iOS: Technical Solutions and Pitfalls
After Google Timeline was shut down in 2024, developers lost a reliable tool for route analysis. Instead of cloud-based solutions, we're now building local apps that accurately track fuel consumption. We'll walk through implementing a GPS tracker in SwiftUI, covering noisy data handling, background optimization, and navigating App Store Review.
Basics of Background Tracking in CoreLocation
The key challenge is obtaining coordinates when the app is backgrounded without killing the battery. Here's what's required:
- Activate
allowsBackgroundLocationUpdatesin CLLocationManager - Add Background Modes entitlement with the location type
- Provide justification in Info.plist via
NSLocationAlwaysAndWhenInUseUsageDescription
The initial implementation, which recorded every point, resulted in excessive data volume (thousands of points per trip). Optimization came from combining these parameters:
desiredAccuracy = kCLLocationAccuracyBest— prioritizes GPS over Wi-Fi/cellular towersdistanceFilter = 10— ignores movements under 10 meters- Filtering by
horizontalAccuracy > 50— weeds out noisy data
These settings cut data down to 200–500 points per trip. With 60+ routes, the database takes up ~15 MB, which is fine for CoreData.
Kalman Filter Without IMU: Math for Highway Tracking
On sections with GPS jamming (Krasnodar–Gelendzhik), classic sensor-based solutions aren't available — CMMotionManager gets blocked in the background. We had to adapt the Kalman filter to work solely with coordinates:
func processLocation(_ location: CLLocation) -> CLLocation {
guard let previous = lastFilteredLocation else {
lastFilteredLocation = location
return location
}
let dt = location.timestamp.timeIntervalSince(previous.timestamp)
let processNoise = dt * speedVariance
predictedVariance += processNoise
let measurementVariance = location.horizontalAccuracy * location.horizontalAccuracy
let kalmanGain = predictedVariance / (predictedVariance + measurementVariance)
let lat = previous.coordinate.latitude
+ kalmanGain * (location.coordinate.latitude - previous.coordinate.latitude)
let lon = previous.coordinate.longitude
+ kalmanGain * (location.coordinate.longitude - previous.coordinate.longitude)
predictedVariance = (1 - kalmanGain) * predictedVariance
return CLLocation(
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon),
altitude: location.altitude,
horizontalAccuracy: sqrt(predictedVariance),
verticalAccuracy: location.verticalAccuracy,
timestamp: location.timestamp
)
}
The algorithm dynamically adjusts trust in measurements: with high horizontalAccuracy (poor GPS signal), kalmanGain drops, and the system leans more on prior data. For signal dropouts longer than 10 seconds, we use post-processing with cubic splines.
Visualizing Routes by Speed in SwiftUI
iOS 17+ offers MapPolyline, but it's limited to one color per object. For dynamic speed-based coloring of routes, we needed to:
- Break the track into segments with uniform speed ranges
- Render each segment as a separate MapPolyline
- Overlap adjacent segments by one point
struct SpeedSegment {
let coordinates: [CLLocationCoordinate2D]
let color: Color
}
func buildSegments(from points: [LocationPoint]) -> [SpeedSegment] {
var segments: [SpeedSegment] = []
var currentCoords: [CLLocationCoordinate2D] = []
var currentColor: Color = .green
for point in points {
let color = speedColor(for: point.speed)
if color != currentColor && !currentCoords.isEmpty {
segments.append(SpeedSegment(
coordinates: currentCoords,
color: currentColor
))
currentCoords = [currentCoords.last!]
}
currentCoords.append(point.coordinate)
currentColor = color
}
if !currentCoords.isEmpty {
segments.append(SpeedSegment(
coordinates: currentCoords,
color: currentColor
))
}
return segments
}
The system uses 4 color ranges:
- Green: < 50 km/h (city)
- Yellow: 50–90 km/h (highways)
- Orange: 90–110 km/h (freeways)
- Red: > 110 km/h
A typical route generates 20–50 polylines. With 500+ points, there can be lag on older devices, but performance is solid for most use cases.
Geohash Fog of War: Geospatial Optimization
For the "fog of war" effect (hiding untraveled sections), we chose geohash over quadtree or direct point storage. Advantages:
- Fast lookup via prefix matching
- Simple serialization to CoreData
- Flexibility via precision parameter
| Precision | Cell Size | Application |
|-----------|----------------|-----------------|
| 5 | ~4.9 × 4.9 km | Country overview|
| 6 | ~1.2 × 0.6 km | City overview |
| 7 | ~150 × 150 m | Detailed view |
Implementation includes:
- Dynamic precision switching based on MKCoordinateSpan
- Checking visible cells via Set<String>
- Dissipation animation with opacity 0.7s easeOut
Current limitation: rectangular cell boundaries. Next up: gradient rendering with edge blurring for a more natural look.
Key Takeaways
- Input filtering is critical: without distanceFilter and horizontalAccuracy checks, the database balloons 5–10x
- Live Activity has strict limits: 1 Hz updates are the max iOS allows
- Geohash beats quadtree for mobile scenarios due to simpler serialization
- Kalman filter without IMU handles up to 10-second signal dropouts
- MapPolyline needs segmentation for dynamic coloring — one route = 20–50 objects
Live Activity: Combating Human Error
Problem: users forget to stop recording, draining the battery. Solution: a Lock Screen indicator via ActivityKit:
// Launch activity
let activity = try Activity.request(
attributes: TripActivityAttributes(carName: "Polo Sedan"),
content: .init(state: initialState)
)
// Update every second
await activity.update(.init(state: updatedState))
Key point: update frequency is capped at ~1 Hz. Sending data more often gets ignored by the system. That's enough for a speedometer, but high-frequency metrics need data aggregation.
Data Storage in CoreData
The database schema is optimized for fast access to routes and points:
TripEntity (1) ──── (*) LocationPointEntity
├── startDate
├── distance
└── fuelCost
LocationPointEntity
├── latitude
├── longitude
└── speed
ExploredCellEntity
└── geohash (unique)
Implementation details:
- NSMergeByPropertyObjectTrumpMergePolicy for geohash cells
- Auto-deletion of old records via timestamp
- Indexing by tripID and timestamp for faster queries
With 500+ points per route, minimizing fetch requests is crucial. All LocationPointEntity operations run in a single managed object context with batch updates.
— Editorial Team
No comments yet.