SignalR, and a Bluetooth Mesh Fallback When Signal Drops: Notes from RideTogether
RideTogether tracks a group of riders live on one map — built for motorcycle touring groups here in Bangladesh, where the moments you need it most (hill roads, rural highways) are exactly where cellular signal disappears.
Every group ride has the same moment: you're moving through traffic with six other riders, and by the next junction, the group's scattered. Someone's stuck at a signal. Someone took a wrong turn. Everyone ends up parked on the shoulder, calling around, hoping the others still have signal. RideTogether puts everyone in a ride on one live map instead — Flutter client, ASP.NET Core and SignalR backend, self-hosted on my own VPS.
The live-tracking part is the obvious feature. The part that actually taught me something was making it survive the exact conditions it's built for: patchy signal, hill roads, and a rider who's occasionally the only one in the group who's lost coverage entirely.
Your own marker can't wait on a network round trip
The first working version updated every rider's marker — including your own — only when a SignalR broadcast came back from the server. That's fine for other riders; it's wrong for yourself. Google Maps' own blue dot doesn't wait for a server round trip to move, and neither should this.
The fix was decoupling two things that had been living in the same code path: how often a phone sends its position (throttled, a real battery tradeoff) and how often its own marker updates on screen (as fast as GPS actually reports). The location service exposes the raw position stream separately from its throttled send loop, and the UI listens to that directly for its own marker while still going through the cloud round trip for everyone else's.
Future<void> _sendCurrentPosition() async {
final position = _latestPosition;
if (position == null) return;
if (signalR.isConnected) {
await _flushQueue();
await signalR.sendLocation(update);
} else if (mesh != null && mesh!.hasConnectedPeers) {
// No cellular right now, but nearby riders are reachable over
// Bluetooth/WiFi - send there instead of queuing for a cloud
// reconnect that might be a while coming on a hill with no signal.
await mesh!.broadcastLocation(update);
} else {
_enqueue(update); // buffered, flushed once the cloud connection returns
}
}
The part that made this worth writing about: mesh fallback
Bangladesh's hill tracts and rural highways are exactly where group rides need this most, and exactly where cellular coverage vanishes. So when a phone loses signal entirely, RideTogether falls back to relaying location over Bluetooth/WiFi Direct — Android's Nearby Connections API — to whichever riders are still in range, who relay it onward again. It won't help if the whole group is scattered across a mountain out of range of each other, but it covers the far more common case: one or two riders losing signal while still riding near the pack.
Getting this right meant a few genuine surprises. Nearby Connections' payload size has a practical ceiling that isn't precisely documented anywhere I could verify, which is why mesh-relayed location pings (and later, push-to-talk voice clips) stay deliberately small and short rather than assuming headroom that might not be there. Relay itself needed a dedup mechanism too — without one, the same ping floods indefinitely between devices still in range of each other. The fix was tagging each ping with an origin timestamp at the point it's first sent, carried through unchanged across every hop, so any device can tell "have I already seen a newer version of this" without needing a central authority.
The pattern that generalizes: a mesh relay needs its dedup key to travel with the message from its origin, not be derived from when each hop happens to receive it. Comparing against local receipt time is a trap that looks like it works in testing (single hop, low latency) and quietly fails the moment a message actually takes multiple hops to arrive.
Android's background execution rules aren't optional reading
A location-tracking app that stops tracking the moment you switch apps to check a map or answer a text isn't much of a location-tracking app. Keeping GPS updates flowing while minimized meant an actual Android foreground service with a persistent notification — not just a background isolate quietly polling, which Android will suspend within seconds regardless of what the code thinks it's doing.
The permission story here is genuinely fiddly: ACCESS_FINE_LOCATION alone isn't enough once you're asking for a foreground service with a wake lock — that's a separate, easy-to-forget manifest permission, and the crash it produces when missing only shows up on a real device, never the emulator, and only once the position stream actually tries to open.
Where the free tier quietly stopped being free
I made a deliberate call early on to keep this off anything needing a Google Cloud billing account. Map tiles from OpenStreetMap, place search through Nominatim, routing through OSRM, photos uploaded to my own VPS instead of Firebase Storage. That last one wasn't originally the plan — Firebase Storage now requires the paid Blaze plan for any use at all, even a single photo, a change from what used to be a genuinely free tier. Same story hit phone-number sign-in, which now needs Blaze too. RideTogether ships with guest sign-in instead of phone auth as a direct result. Worth checking whether a "free tier" assumption from even a year ago still holds before building around it.
Takeaways for anyone building real-time location tracking
- Separate "how often do I send data" from "how often does my own UI update" — the first is a bandwidth/battery tradeoff, the second should be as fast as the source data actually arrives.
- A mesh relay's message dedup has to be keyed on something that travels with the message from its origin, never on local receipt time.
- Background location tracking on Android is a foreground-service problem before it's a Dart problem — test on a real device, not just the emulator, before trusting it works.
- Re-check "free tier" assumptions on any cloud service before architecting around them — Firebase Storage and Phone Auth both moved behind paid billing since I'd have otherwise assumed.