Workstation4 / Blog / Swift
Swift CoreLocation Observation Bug Fix

A drive timer that froze at every red light.

Ghost Car records your drives second by second. The elapsed-time field on the recording screen was, I thought, doing the obvious correct thing: subtracting the drive's start time from Date(), the real wall clock. So I was genuinely surprised the first time I sat at a long light and watched the number stop. The arithmetic was right. The problem was that nothing was running it. This is the story of an entire class of bug I had not really thought about before: code that is correct but only executes when the wrong thing happens.

How a drive gets recorded

Ghost Car's recording lives in a single LocationManager, an @Observable class that wraps a CLLocationManager and exposes the live values SwiftUI binds to: speed, distance, duration, averageSpeed, maxSpeed, and the growing routeCoordinates array. Because it is marked @Observable (the newer Observation framework, not the older ObservableObject protocol), any view reading duration redraws automatically whenever that property changes. That detail matters later: the view was never the problem. The view faithfully showed whatever duration last held.

The manager is configured for accurate driving data rather than for power thrift:

manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = 10               // meters
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = false

The distanceFilter = 10 is the quietly important line. It tells Core Location not to bother delivering a new location until you have moved roughly ten meters from the last one. For a route path that is exactly right. Without it you would collect a pile of near-identical coordinates while parked, and the drawn line would wobble from GPS jitter even when the car is dead still. With it, the recorded path is clean.

The bug was not the formula, it was the trigger

Here is the part I got wrong in my head. I assumed that because duration was computed from Date() and not from GPS timestamps, it was immune to anything GPS did. The computation looked like this, and it is fine:

if let start = startTime {
    duration = Date().timeIntervalSince(start)
}

The catch is where that line lived. It sat inside locationManager(_:didUpdateLocations:), the delegate callback Core Location invokes when it has a fresh location for you. So the recipe for advancing the timer was, in effect: "every time a new location arrives, recompute the elapsed time." While the car is moving and crossing a ten meter threshold every fraction of a second, locations pour in and duration updates so smoothly it looks like a clock.

Then you stop. At a red light you are not moving ten meters. Core Location, doing precisely what the distance filter asked of it, goes silent. No callback fires. The line that recomputes duration never runs. The wall-clock formula is sitting right there, ready to give the correct answer, and nothing ever calls it. The number freezes, even though the system clock has marched on the whole time.

The math was reading a clock. But the math only got to run when a sensor fired. I had hung a clock's job on a sensor's heartbeat, and a sensor only beats when the world changes.

This is what made it sneaky. If I had been reading elapsed time off GPS timestamps, the bug would have been obvious in code review. Instead the code looked unimpeachable in isolation. You had to think about the call site, not the call, to see it.

Why it sailed through testing

Every test I ran involved driving. You start a recording, drive around the block, glance down, watch the seconds climb, stop the recording, check the saved drive. It passes every time, because the failure only appears in the one situation the app is built for: a real commute with real lights, real stop signs, and real time spent parked while still nominally "recording." The bug is invisible to motion and only visible to stillness, which is the opposite of how you naturally test a driving app.

The fix: give the timer its own heartbeat

The fix is to stop borrowing the GPS callback as a trigger and give the displayed duration a clock of its own. When tracking starts I now spin up a one-second repeating Timer whose only job is to recompute duration from the wall clock, completely independent of whether any location has arrived:

// when tracking begins
durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
    guard let self = self, let start = self.startTime else { return }
    self.duration = Date().timeIntervalSince(start)
}

// when tracking ends
durationTimer?.invalidate()
durationTimer = nil

Now the timer ticks every second no matter what the car is doing. Light or no light, motion or no motion, duration advances, and because the class is @Observable, the on-screen field redraws each time. I deliberately left the original recompute inside didUpdateLocations in place. It costs nothing and acts as a backstop: if a location happens to arrive between ticks, the duration is simply recomputed a hair early with the same correct value. There was no need to remove a line that was never wrong.

Two small details keep this timer well behaved inside a long-running, background-capable location manager:

  • Weak capture. The closure captures self weakly. A scheduledTimer retains its target, and this manager outlives many things, so a strong capture would create a retain cycle and keep the manager alive after the drive should have let it go.
  • Explicit invalidation. stopTracking() calls durationTimer?.invalidate() and nils it out. Without that, the timer would keep firing forever, including in the background where the app has location permission to run, quietly burning a tick a second for no reason.

Notably, I did not touch the distance filter. Loosening it to ten centimeters would have "fixed" the timer by flooding the callback with locations, but it would have wrecked the route data and the battery to paper over a display bug. The sensor was correct. The clock just needed to be a clock.

The value you show and the value you save

Untangling this also clarified something about duration having two audiences. The displayed value needs to advance smoothly in real time for the driver watching the screen. The persisted value is what gets written into the SwiftData Drive record when the drive ends. In Ghost Car these happen to be the same number, computed the same way from startTime, so I did not need to split them into separate properties. But the recording pipeline already saves a checkpoint every ten coordinates, and the final stopTracking() writes drive.duration = duration at the end. Because the timer keeps duration honest the whole time, the value baked into the saved drive is correct even if the last leg of the trip was spent idling in a driveway.

What I took away

The lesson that stuck was not really about Core Location. It was about separating what a piece of code computes from what causes it to run. My formula was reading the right source the entire time. It just never got invited to the party when the car stopped moving. When a value needs to change on a schedule, it needs to be driven by something that ticks on a schedule, not by an event that may or may not occur.

  • Correct code is not enough; ask what triggers it, and whether that trigger fires when you actually need the value to update.
  • A distance filter that produces clean route data will, by design, go completely silent at every stop. That silence is a feature for the path and a trap for anything riding on the callback.
  • Fix the layer that is actually wrong. Do not loosen a sensor to paper over a display problem.
  • Any Timer living inside a long-running, background-capable object needs a weak capture and an explicit invalidate(), or it will outlive its welcome and keep firing where you cannot see it.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →