Building a GPS Speedometer, and Why the Hard Part Was Lying Less

A speedometer app sounds like a weekend project. Read the speed from GPS, put it on the screen in big numbers, ship it.

I thought that too. Then I built the naive version, drove around with it, and watched it tell me I was doing 47, then 3, then 51, then 0, while sitting at a steady 45 on a straight road. The number on the screen was technically the value the operating system had handed me, and it was useless.

Everything interesting about Speedometer GPS is in the gap between the raw reading and a number a person can actually use.

Where GPS speed comes from #

Two ways to get speed out of GPS, and they are not equally good.

Differentiating position: take two position fixes, compute the distance, divide by elapsed time. Simple, and terrible. Position fixes have error of several meters, and if you differentiate a noisy signal you amplify the noise. Two consecutive fixes with five meters of independent error, one second apart, give you a phantom velocity of up to ten meters per second out of nothing. That is 22 mph of pure noise on top of your real speed.

Doppler shift: GPS receivers can measure the frequency shift of the satellite signals caused by your motion relative to the satellites, and derive velocity directly. This is far more accurate than differentiating position, often within a fraction of a meter per second.

Modern mobile operating systems give you a speed value that generally comes from the better method, and expose an accuracy estimate alongside it. Using that accuracy estimate turned out to matter more than anything else I did.

Why the raw number still jumps around #

Even with Doppler-derived speed, the readings are messy in real driving.

Buildings and terrain. Urban canyons bounce signals off surfaces, so the receiver sees a path longer than the direct one. In downtown San Francisco, between the hills and the towers, this is constant.

Tunnels and overpasses. Signal drops entirely, so you get either nothing or a wild reading as it reacquires.

Low speeds. At walking pace the Doppler signal is small relative to the noise floor. A stationary phone frequently reports one to three mph of movement, which is why a naive app tells you that you are driving while parked.

Update rate. Most receivers deliver one fix per second. Between fixes you have no information at all, so the display either freezes or you invent something.

Cold start. The first fixes after acquiring signal are the worst ones, and they arrive exactly when the user has just opened the app and is forming an opinion about it.

The filtering problem, and the tradeoff nobody escapes #

The obvious fix is smoothing: average the last several readings so a single bad one does not dominate. That works, and it introduces the central tradeoff of the entire app.

Heavy smoothing gives you a rock-steady display that lags reality. You brake hard and the number keeps reading your old speed for two or three seconds. A speedometer that is confidently wrong during exactly the moment you cared about it is worse than a jumpy one.

Light smoothing is responsive and jittery. The number flickers between 44 and 47 constantly, which is visually exhausting and makes the app feel broken even though it is more accurate.

There is no setting that is right for both cases, so the answer is to not use one setting.

Adaptive filtering is what I landed on. The amount of smoothing varies based on what is happening. When consecutive readings agree closely, trust them and respond fast, so steady cruising gives you a steady, responsive number. When a reading disagrees sharply with recent history, treat it with suspicion and smooth harder, because a jump from 45 to 3 in one second is not physically possible in a car. When the OS reports poor accuracy on a fix, weight it less, which is the free signal most implementations ignore and the best one available. And when speed is near zero, clamp to zero rather than displaying the noise floor, because a parked car should read 0, not 2.

This is a simplified cousin of what a proper Kalman filter does, and I went back and forth on whether to implement a full one. The adaptive heuristic captured most of the benefit and I could reason about its behavior when it did something strange, which mattered more to me than theoretical optimality in an app one person maintains.

Rejecting the impossible #

The other half of the fix is a physics check. Cars have bounded acceleration.

A reading implying you went from 60 to 0 in one second, or from 30 to 90, did not happen. Those get rejected rather than smoothed, because averaging in an impossible value still moves the display toward it.

The threshold has to be generous enough not to reject genuine hard braking, which is a real thing that happens at exactly the moment accuracy matters most. I set the bound well above normal driving dynamics and let anything beyond it through only if a second consecutive reading agrees. One implausible reading is noise. Two in a row is an event.

Designing for a two-second glance #

The interface constraint is unlike any other app I have built: the user is driving. They look at the screen for under a second, at arm’s length, in variable light, while operating a vehicle.

That drove every decision. One enormous number, the current speed, dominating the screen, with everything else secondary and looking it. If you have to search the screen for the speed, the design has failed. A dark background, because at night a bright screen in a car destroys your night vision and reflects off the windshield, which makes dark a safety consideration rather than a style preference. High contrast and no thin fonts, legible at a glance, in sunlight, without focusing. No interaction required while moving, so everything that needs a tap happens before you drive and trip stats accumulate on their own. And haptic feedback on the few controls that exist, so you get confirmation without looking.

I also spent longer than expected on GPS signal quality indication. The app knows when its reading is unreliable, because the OS tells it. Hiding that and showing a confident number anyway would be the easy choice and the dishonest one. So poor signal is visible, and the user knows to trust the number less rather than being quietly misled.

That principle, showing uncertainty rather than papering over it, is the thing I would defend hardest about the app.

Trip statistics, and the distance problem #

Beyond current speed, the app tracks max speed, average speed, and total distance for a trip.

Distance is where it gets subtle. Integrating filtered speed over time accumulates error, and small biases compound over a long drive. Summing GPS position deltas is more accurate over distance but inherits position noise. I ended up using position deltas with a minimum-movement threshold, so that a stationary phone with jittering fixes does not slowly accumulate fictional miles while you sit at a light. Without that threshold, a parked car accrues about a mile an hour of imaginary distance, which I discovered by leaving it running in a parking lot.

Battery, which is the real constraint #

Continuous high-accuracy GPS is one of the more power-hungry things a phone does. An app that visibly drains the battery gets deleted, regardless of how good the filtering is.

What helped: requesting the accuracy tier that matches the use case rather than maximum accuracy always. Stopping location updates the instant the app is not in the foreground, with no background tracking, which is also a privacy decision and simplifies the permission story enormously, because the app can ask for while-in-use permission rather than always-on. And keeping the render loop cheap, since updating a large number once a second does not require redrawing a complex scene at 60 frames per second, and the naive implementation did exactly that.

No servers, no accounts, no uploads #

Location data is among the most sensitive categories of personal data there is. A month of someone’s location history reveals where they live, where they work, who they visit, and what they do on weekends.

So the app has no backend. Nothing is uploaded, there is no account, and location data stays on the device and is not persisted beyond the trip. This is the same position I have taken with on-device AI and for the same reason: a privacy property enforced by architecture is worth more than one asserted in a policy, because there is no code path for the data to leave.

It also means the app is free with no subscription, because there are no server costs to recover.

The safety note I insisted on #

A speedometer app is not a license to speed and it is not a replacement for your car’s instruments. GPS speed is generally accurate on the open road and degrades in tunnels, urban canyons, and parking garages. Your car’s speedometer is legally required to not under-read and typically reads slightly high by design.

Where a GPS readout genuinely helps: rental cars with unfamiliar dashboards, checking whether your car’s speedometer is calibrated correctly, cycling, and knowing your trip distance. I wrote about the rental car case specifically in cheap road trips out of San Francisco, which is when I use it most.

What I took away #

The naive implementation of a simple app is usually wrong in a way that only shows up in the field. Nothing about the noise problem was visible from my desk. It appeared the first time I drove with it.

Signal processing is a product problem, not just a math problem. The right amount of smoothing is determined by what the user needs to do with the number, not by minimizing error.

Showing uncertainty is a feature. Every instinct pushes you toward a confident display. Resist it when the underlying data does not support confidence.

And designing for a two-second glance is a genuinely different discipline from designing for attention. Most of what I knew about interface design did not transfer.

More builds in the same spirit: an offline AI chat app, a private speech-to-text app, and a credit card tracker. The pattern across all of them is in what shipping six apps solo taught me.