Back to blog
FILE 0xDE·THE RADIO KNOWS SOMETHING GPS DOESN'T

The radio knows something GPS doesn't

September 7, 2026 · bluetooth, gps, ios, android

Two people, one convention hall, eighty-five thousand attendees, and a cell network that stops working at exactly the moment anyone needs it. The question is never what are your coordinates. It's which way do I walk.

I had a working answer already: both phones report position in the background, a server does the trigonometry, a page shows a distance and a bearing. It works. It also, indoors, routinely tells you your person is 16 metres away when the honest answer is "somewhere in this building, probably."

Age is uncertainty, so put it in the same budget

The first version of the board treated a stale fix as a failure — red banner, "waiting for a live position". That's wrong, and it's wrong in an expensive way: one of the phones reports every ten to twenty minutes by design, so a fifteen-minute staleness threshold paints a perfectly healthy source permanently broken.

A fix's age isn't a health problem. It's an error term. Someone walking in a crowd covers about 1.1 m/s — below a free walking pace, because the crowd is the whole point — so a five-minute-old fix is a circle 330 m across, and it belongs in the same slack as the reported accuracy:

def drift_m(age_s):
    """How far someone could have walked since a fix was taken."""
    if not age_s or age_s <= 0:
        return 0.0
    return min(age_s * WALK_SPEED_MS, DRIFT_CAP_M)

Capped, because past a few hundred metres the honest answer stops being a distance at all and becomes "no idea, walk to the meetup point." An unbounded slack would make every reading say "same area", which is a lie in the other direction.

Then the rule that falls out of it: when the separation is smaller than the combined slack, don't print a number.

if dist <= slack:
    confidence = "same-area"      # "same area — can't do better than that"
elif dist <= slack * 2:
    confidence = "rough"
else:
    confidence = "good"

"Same area, look up" is the single most useful sentence that screen can show. It took me two rewrites to stop burying it under a staleness warning.

Where the radio comes in

None of that fixes the actual failure mode, which is being twelve feet apart with a wall in between and two GPS fixes that disagree by eighty metres.

But if the two phones can hear each other's Bluetooth advertisement, they are within tens of metres of each other. That's not an estimate; it's the propagation range of a 2.4 GHz radio. No satellites, no venue Wi-Fi, no data connection at all. In the situation where every other signal has degraded, this one is at its best — because it only works when you're close, which is precisely when you care.

So the ladder inverts at short range. A radio contact becomes the headline and GPS gets demoted to supplying the bearing, which RSSI cannot give you.

Each party advertises its own service UUID and scans for the other's, rather than sharing one UUID and putting an identity in the payload. Two reasons: scanning by service UUID is the only scan iOS honours while backgrounded, and it means contact is established by the advertisement alone — no GATT connect, no pairing, no round trip to lose on a band that is saturated with eighty-five thousand other radios.

There's an asymmetry worth knowing before you design around it: a backgrounded iOS app moves its service UUID into the advertisement's overflow area, which only other Apple devices can decode. iOS-in-background → Android is invisible. Android → iOS, iOS-foreground → anything, and iOS ↔ iOS all work fine. So both sides scan and advertise, and the server takes the freshest contact regardless of which side heard it — range is symmetric even when the platforms' scanning rules are not.

The part I had to argue myself out of

RSSI converts to distance with a log-distance path-loss model:

est_m = 10 ^ ((txPowerAt1m - rssi) / (10 * n))

with n = 2.4 rather than the free-space 2.0, because a crowd is a room full of salt-water bags and 2.4 GHz hates them. Filter before you believe any of it — median of the last five samples, then an EMA — since a single raw RSSI swings ±10 dB while you stand perfectly still, which is a factor of 2.6 in range.

And then: never show the number. The UI shows a band.

arm's reach · very close · close · nearby · in range

The estimate goes over the wire for the server's benefit. The person holding the phone gets a phrase. Printing "4.2 m" off an RSSI is selling precision that does not exist, and the entire design here is one long argument against doing that.

I nearly undid it by accident. When a radio contact exists, I had the server mark the whole relationship as high-confidence — reasonable-sounding, and completely wrong. The contact bounds the separation; it does nothing to the satellite fixes, and confidence describes those. The result was the board printing 16 m in confident 46-point type on top of two fixes carrying 757 m of combined slack — the exact false precision the rest of the module exists to refuse. The fix was four deleted characters and a comment explaining why the line is not coming back.

Two smaller things that mattered more than they should

Serve the constants. The BLE UUIDs, the band thresholds, and all four cadences come from a config endpoint and are cached to disk. These apps are sideloaded onto phones I can't debug from a hotel lobby; a hardcoded UUID is the hardest value in the system to change, because there's no store to push a fix through. Now a knob turn is a server restart instead of a reinstall on someone else's phone.

Scope your service worker from the root. The web board is an installable PWA so it opens with the last known answer instead of a spinner in a basement with no data. A worker served from /static/ can only ever control /static/ — it will register happily and silently control nothing. It needs its own route at the root.

What it looks like now

Radio contact if there is one, band not metres. Otherwise a sentence derived from course over ground — "about 300 feet ahead and to your left" — because the alternative interaction is hold-the-phone-up-and-rotate-your-body, and that's a lousy thing to require of someone in a moving crowd. Then the fix ages, honestly labelled, and a meetup pin underneath that doesn't move and doesn't go stale.

Five rungs, each surviving the failure of the one above it: radio, live fix, background stream, pin, SMS. The interesting engineering wasn't any single rung. It was refusing to let a good rung's confidence launder a bad one's.