Open a GPS speedometer cold and the very first thing the hardware hands you is often misleading. A fresh fix can arrive carrying a stale, cached reading, and an app that trusts it will flash a wild number the instant it opens. I wanted the big digit to start at zero and climb honestly, so I layered five small guards into the location callback before any reading is allowed to drive the display. None of them is clever on its own. Together they are the difference between a speedometer you believe and one you do not.
Why the spike happens
CoreLocation does not start from nothing. When you call startUpdatingLocation(), the system frequently replies first with whatever it had cached from the last app that touched the radio. That cached CLLocation can be seconds or minutes old, taken somewhere else entirely, carrying a timestamp from before your app even launched. Compute speed from the gap between that ghost and your first real fix and you get nonsense: a phone on a desk reporting sixty, a slow walk reporting a hundred.
For most apps this is invisible, because they smooth or quietly ignore raw speed. A speedometer cannot hide it. The entire interface is one big monospaced number, and that number is the first thing a person reads. So the launch sequence turned into some of the most careful code in the project.
It does not help that I deliberately asked for the most aggressive location settings. The app configures the manager for navigation, which means more frequent, more sensitive updates, and that includes more chances for an early bad one:
locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
locationManager.distanceFilter = kCLDistanceFilterNone
locationManager.activityType = .fitness
With kCLDistanceFilterNone every update is delivered, not just ones past a movement threshold, and bestForNavigation keeps the GPS hardware fully awake. Those choices make the live reading responsive once things settle, but they also mean nothing upstream is protecting me from the messy first second. The filtering has to live in my callback.
The five guards
Rather than one heuristic, I leaned on five cheap, independent checks inside didUpdateLocations. Each is easy to reason about alone, and a reading only reaches the display if it survives all five, in order.
- A two-second warmup. The very first fixes after launch are simply dropped. The radio is still settling and the cached point is most likely to appear here, so I let it pass without ever showing it. I compare against an
appStartTimecaptured ininit(). - A staleness cutoff. Any location whose timestamp is older than fifteen seconds is rejected outright. This is the single most effective guard against the cached-fix problem, because the ghost reading almost always announces itself with an old timestamp.
- An accuracy floor. A
horizontalAccuracybelow zero means the fix is invalid, so it is discarded immediately. Beyond that I reject anything worse than 100m, loosened to 200m during the first fifteen seconds while the lock is still tightening. - A two-location minimum. No speed is computed until I have at least two accepted locations to compare. On the first accepted fix I just stash it in
previousLocationand return. One point is a position, not a velocity. - A startup clamp. In the first ten seconds, any fix whose reported speed exceeds 10 m/s, about 22 mph, is thrown away. A phone that was sitting on a desk did not just hit highway speed, so I treat that as a glitch rather than a measurement.
Here is the heart of the gate, close to the real source. Note that the warmup and staleness checks come first and cheapest, so a bad fix is rejected before any math runs:
let now = Date()
lastGPSLocationTime = now // record that GPS is alive, even if we filter this one
let timeSinceAppStart = now.timeIntervalSince(appStartTime)
if timeSinceAppStart < 2.0 { return } // 0: warmup
if now.timeIntervalSince(location.timestamp) > 15.0 { return } // 1: staleness
let accuracy = location.horizontalAccuracy
if accuracy < 0 { return } // 2a: invalid
let maxAccuracy = timeSinceAppStart < 15.0 ? 200.0 : 100.0
if accuracy > maxAccuracy { return } // 2b: weak lock
if previousLocation == nil { // 3: need two points
previousLocation = location
return
}
if timeSinceAppStart < 10.0 && location.speed > 10.0 { return } // 4: clamp
Two ways to read speed
Once a fix survives the gate, I still have to turn it into a number. CoreLocation exposes location.speed directly, but it is not always populated; a fix can arrive with the field unset, reported as a negative value. So the speed calculation has a primary path and a fallback.
The primary path trusts location.speed, but only when it is non-negative and below a sanity ceiling of 45 m/s, roughly 100 mph. Anything above that, on a phone in someone's hand, is far more likely to be a sensor artifact than a real reading. When the built-in speed is missing or rejected, I fall back to first principles and recompute it from distance over elapsed time between the two accepted points:
let distance = location.distance(from: prevLoc)
let timeInterval = location.timestamp.timeIntervalSince(prevLoc.timestamp)
if timeInterval >= 0.5 && timeInterval < 10.0 {
let calculated = distance / timeInterval
if calculated < 45.0 && calculated < 20.0 { // ceiling + per-step clamp
rawGPSSpeed = calculated
}
}
The derived path needs guards the built-in value does not, because dividing a real distance by a tiny time interval produces enormous numbers. So I only compute when the interval sits between 0.5 and 10 seconds, and I reject any result implying more than 20 m/s of change between consecutive points. The idea mirrors the startup clamp: a value that is physically implausible for one GPS step is treated as a timing artifact, not a truth.
The sensor floor underneath
Filtering aggressively has a cost: sometimes you reject everything and have nothing left to show. Indoors, in a parking garage, or in a tunnel, the GPS either goes silent or comes back so inaccurate that the accuracy floor discards it. A speedometer that freezes at its last value in those moments feels broken.
So the rejection layer sits on top of a motion layer. The accelerometer runs through CMMotionManager at 60 Hz, computing the magnitude of userAcceleration on every sample to answer one question instantly: is this device moving at all?
let a = motion.userAcceleration
let magnitude = sqrt(a.x*a.x + a.y*a.y + a.z*a.z)
isMoving = magnitude > 0.08 // tuned for walking
Alongside it, CMPedometer reports currentPace in seconds per meter, which inverts cleanly into a walking speed in meters per second. A separate two-second timer watches lastGPSLocationTime; if no GPS location has arrived for eight seconds, it switches the display to whatever the pedometer reports, or a fixed indoor walking estimate of 1.12 m/s if the pedometer is quiet, and flags the reading as estimated so the UI can dim it. Crucially, max speed is only ever updated from real GPS, never from these estimates, so the fallback can keep the digit alive without polluting the record.
Why five instead of one
It was tempting to write a single smart filter, maybe a moving average or a Kalman-style estimator, and call it done. I chose not to, at least for now. Each rule answers a different failure: the warmup handles radio settling, the staleness cutoff handles cached ghosts, the accuracy floor handles weak locks, the two-location rule handles the cold start, and the clamp handles the absurd outlier. When something does slip through, I can see exactly which guard to tighten, and tightening one does not quietly distort the others. With a single estimator, a launch spike would just mean nudging an opaque parameter and hoping.
There is a deeper reason too. The smoothed data series in this app is, on purpose, not smoothed at all; it appends the raw accepted value so the digit responds instantly. That only works because the data reaching it has already been cleaned by rejection rather than by averaging. I would rather drop a suspicious reading than blend it into the truth.
The number that matters most is the first one, and the honest first number is zero.
That is the part I keep coming back to. A speedometer's credibility is set in its first couple of seconds. If it spikes on launch, every accurate reading afterward inherits the doubt. The quiet, unglamorous work of rejecting bad data, rather than smoothing over it, is what lets the digit climb from zero in a way you can trust.
Takeaways
- Treat the first GPS fixes as suspect; a fresh callback can carry a stale cached reading from a previous app.
- Prefer several small, independent guards over one opaque filter, so each failure mode has a name and a knob.
- A timestamp age check is your cheapest, strongest defense against ghost fixes; run it before any speed math.
- Have two ways to read speed: trust
location.speedwhen it is valid, recompute from distance over time when it is not, and clamp the physically impossible in both paths. - Filtering hard means sometimes having nothing left, so keep a sensor fallback underneath, and never let estimates touch your recorded max.
- Starting at zero and climbing honestly beats starting fast and being wrong.