Live rider tracking with Redis GEO and WebSockets.
How rider GPS flows from a phone to a Redis GEO index, through pub/sub to WebSocket and SSE viewers, and into a marker that glides instead of jumping.
When a customer opens the tracking screen, they want to see the rider moving toward them. Not a pin that sits still and then jumps across town. Operations staff want the same thing for every rider at once, on one map. This case study covers the whole path: the rider's phone sends a GPS fix, the server records it, a live index in Redis keeps it, a fan-out layer pushes it to everyone watching, and the client turns a sparse stream of points into motion that looks continuous.
The problem and the constraints
Rider phones are mid-range or cheap Android devices on mobile data, riding through towns where coverage comes and goes. Fixes arrive irregularly: sometimes every few seconds, sometimes after a long gap, sometimes in a burst when the connection comes back. Location writes are also the busiest traffic in the whole system by a wide margin, because every active rider reports on a short interval all day.
There were three other constraints. First, the realtime stack had to stay wire-compatible with app builds already installed on riders' and customers' phones, so I could not change the protocol and wait for everyone to update. Second, recording a rider's position must never fail because of something optional, such as an index or a broadcast. The GPS record is what proves where a rider was if a delivery goes wrong. Third, I am one developer running this on a small server, so the design has to be simple to operate and cheap at rest.
The first version had a quiet bug that set the direction for the rest. The old PHP WebSocket server kept its channel subscriptions in memory, inside each process. When I ran several processes behind nginx for capacity, a rider connected to one process and a customer watching that rider connected to another never met. Broadcasts reached only the subscribers who happened to share a process with the sender. Nothing errored. Updates just went missing.
What I built
The pieces, in the order a GPS fix travels through them:
- Ingest. The rider app posts its position over HTTP. Older builds can also send frames over a raw WebSocket. Both paths end up in the same Go handler.
- Durable record. The position is written to SQL, throttled per rider so a burst of fixes does not become a burst of writes. A breadcrumb trail is stored the same way.
- Live index. A Redis GEO set per zone holds every active rider's latest position. A sorted set per zone records when each rider was last seen, and a small per-rider hash holds vehicle, zone and active flag, with an expiry.
- Fan-out. Each fix is published once on a Redis pub/sub channel. The realtime server relays it to every customer, website and admin socket watching that rider. A separate Server-Sent Events stream feeds the operations map.
- Motion on the client. The apps and the web map animate the marker between fixes, so it keeps moving instead of stepping.
How it works in detail
The live index never touches SQL. It answers one question quickly: which riders are in this zone, where are they, and how fresh is that information. Every write to it is a pipelined batch: add to the GEO set, set the last-seen score, refresh the meta hash. Every call is wrapped so that a Redis error is logged and swallowed. If Redis is slow or briefly unavailable, the SQL record still lands and the rider app still gets its acknowledgement. The index is a convenience. It is never allowed to become a dependency of GPS recording.
// track records a rider in the live index. It must never fail the caller:
// the durable SQL write has already happened, this is best effort.
func (ix *LiveIndex) track(ctx context.Context, r Rider, lat, lng float64, now time.Time) {
if r.ZoneID == 0 || !validCoord(lat, lng) {
return
}
id := strconv.FormatInt(r.ID, 10)
pipe := ix.rdb.Pipeline()
pipe.GeoAdd(ctx, ix.geoKey(r.ZoneID), &redis.GeoLocation{Name: id, Longitude: lng, Latitude: lat})
pipe.ZAdd(ctx, ix.seenKey(r.ZoneID), redis.Z{Score: float64(now.Unix()), Member: id})
pipe.HSet(ctx, ix.metaKey(r.ID), "zone", r.ZoneID, "vehicle", r.VehicleID, "active", r.Active, "seen", now.Unix())
pipe.Expire(ctx, ix.metaKey(r.ID), ix.metaTTL)
if _, err := pipe.Exec(ctx); err != nil {
slog.Warn("live index write failed", "rider", r.ID, "err", err)
}
}// track records a rider in the live index. It must never fail the caller:
// the durable SQL write has already happened, this is best effort.
func (ix *LiveIndex) track(ctx context.Context, r Rider, lat, lng float64, now time.Time) {
if r.ZoneID == 0 || !validCoord(lat, lng) {
return
}
id := strconv.FormatInt(r.ID, 10)
pipe := ix.rdb.Pipeline()
pipe.GeoAdd(ctx, ix.geoKey(r.ZoneID), &redis.GeoLocation{Name: id, Longitude: lng, Latitude: lat})
pipe.ZAdd(ctx, ix.seenKey(r.ZoneID), redis.Z{Score: float64(now.Unix()), Member: id})
pipe.HSet(ctx, ix.metaKey(r.ID), "zone", r.ZoneID, "vehicle", r.VehicleID, "active", r.Active, "seen", now.Unix())
pipe.Expire(ctx, ix.metaKey(r.ID), ix.metaTTL)
if _, err := pipe.Exec(ctx); err != nil {
slog.Warn("live index write failed", "rider", r.ID, "err", err)
}
}Note the longitude-then-latitude order. Redis GEO uses it while most of the rest of the code thinks in latitude first, and one of the client libraries takes positional arguments that make the swap easy to miss.
The index prunes itself. Riders who stop sending fixes are not removed by a cron job. Their meta hash expires, and readers drop members whose last-seen score is too old. Because every fix rewrites a rider's entries, the index also recovers on its own if Redis evicts keys under memory pressure. Within a few seconds of the next fix, the rider is back.
SQL writes are gated, not timed. A rider can send fixes faster than the database needs them. Instead of a timer per rider, the handler sets a short-lived Redis key with set-if-not-exists. Whoever sets it writes to SQL, and everyone else within that window skips. If Redis fails, the handler writes to SQL anyway, so the durable path degrades toward more writes, never fewer.
One process, one hub. I fixed the missing-broadcast bug in two steps. The first was a stopgap in PHP: every WebSocket process subscribed to a Redis channel and re-broadcast to its own local sockets, so a rider on one process reached a customer on another. I chose that over switching to a different realtime framework, because the alternative would have meant rewriting the client protocol and shipping new builds of two apps. The second step was the real fix: I rewrote the realtime server in Go. One Go process holds every connection in a single hub, so a broadcast reaches every subscriber.
The Go server speaks the same Pusher-style protocol the apps already use, plus the two raw sockets for rider ingest and order tracking, and sends frames that are byte-identical to what PHP sent. That meant structs instead of maps, so JSON keys keep PHP's order, and an explicit flush of the last frame before closing a socket. No app needed a rebuild. I ran it unrouted next to the PHP servers, switched tests over, then production, and kept the PHP units installed but stopped as a rollback until I was sure.
GPS ingest moved to Go too. Later, the rider location endpoint itself moved into the Go API through the per-route switch described in the migration case study. Go now does the position work directly: the gated SQL write, the breadcrumb, the live index and the pub/sub publish. Laravel only gets the order-related half, such as "your rider is almost there" pushes, and only while the rider has an active order, throttled, in the background. The busiest call in the system no longer boots PHP.
Who may watch whom. Watching a rider requires a signed tracking ticket tied to the rider and the order, issued only to people entitled to it, such as the customer of an in-flight order. Ticket and connection are re-checked periodically, so a socket closes when the order is finished. Realtime channel names for chat and order status are signed rather than guessable, and the server accepts subscriptions only to names on an allow-list. Location is personal data, so the default answer is no.
Server-Sent Events for the operations map. The ops console shows every rider in the staff member's zones moving live. A browser map does not need a bidirectional socket, so it uses SSE from the Go admin API. Each Go process holds a single Redis subscription, shared by every open stream. It starts with the first viewer and stops a little after the last one leaves. Updates are coalesced per rider per client so a fast phone cannot flood a slow laptop. Heading and speed are derived from the previous point when the phone does not send them. Heartbeats keep proxies from closing idle streams. Each admin's stream is scoped to their zones, the session is re-checked periodically, and a client that stops reading is dropped rather than buffered. There are caps per admin and per process.
Turning points into motion. A marker that jumps to each new fix looks broken, even when the data is perfect. On the customer app, in native Swift and Kotlin, the marker glides from its currently displayed position to each new fix. The glide duration is not fixed. It follows a moving average of the actual gap between fixes, slightly stretched and clamped, so the marker is almost always in motion and rarely sits waiting. My first version used a short fixed glide, so with real fix intervals the marker moved, stopped, waited, and jumped. A stutter that was plainly visible on the tracking screen came down to that one constant.
Other rules on the customer map:
- When the rider is close to the planned route line, the marker follows the line and turns with each segment instead of cutting corners.
- The route line is trimmed so it starts at the marker.
- A very large step, or a long silence, teleports the marker instead of animating a rider flying across town.
- Tiny duplicate fixes are ignored.
- The rider sprite has a set of heading frames. Choosing the nearest frame on every update made it flicker at frame edges, so it now needs a clear margin past the edge and holds a frame for a moment unless the turn is sharp.
The ops web map uses the same idea in TypeScript. It also dead-reckons briefly along the last heading when a fix is late, then eases into the real position when it arrives. One animation loop drives every marker and idles when nothing moves, and the map respects the reduced-motion setting.
Trade-offs and what went wrong
Lazy pruning needs readers. "Stale members are removed on read" sounds clean. It only works if something reads every zone. For a while nothing read some zones, and riders who went offline were never explicitly removed, so the GEO sets slowly collected ghosts. The rule I ended up with is that every reader filters by last-seen freshness and the active flag, whatever the set contains. The set is a candidate list, not the truth.
One process is a single point of failure. Moving from several PHP processes to one Go process fixed the broadcast bug. It also means a restart drops every socket at once. In practice the apps reconnect with backoff and resubscribe, and Go restarts quickly, so this is acceptable at Laayo's size. It would not be at a much larger scale, and the pub/sub layer is already in place for running more than one node later.
Byte compatibility slowed the rewrite. Matching PHP's frames exactly took longer than writing a clean protocol would have. It was still worth it, because the switch did not depend on anyone updating an app.
A shared Redis. The index lives in a Redis instance that also holds cache data under an eviction policy. The self-healing design makes that tolerable, but a dedicated instance for live state would be cleaner. It is on the list.
A web server default that broke WebSockets. A hosting-panel template hid the upgrade response header at the server level, and strict WebSocket clients rejected the handshake. A location-level header rule fixed it, because in nginx a location-level rule replaces the inherited set. This kind of bug takes an afternoon to find and one line to fix.
Dispatch: what is live and what is in shadow
The index was built with dispatch in mind, so I want to be precise about how it is used today. Live dispatch is still a broadcast. When an order needs a rider, every eligible rider in the zone gets the offer, and the first to accept wins, protected by a row lock so two riders cannot both take it. The live index does not choose riders in production.
What the index does power:
- The ops console's nearby-riders list. Staff assigning an order by hand see eligible riders sorted by distance to the store before pickup, or to the customer after, with how fresh each position is.
- A dispatch service in Go running in shadow. For each order that needs a rider, it builds the same candidate list the broadcast would use, reads positions from the index with freshness filtering, and scores each rider. The score covers travel time to the store, penalties for a stale or missing position or for being offline, current load, and acceptance history. It records its pick next to what actually happened. It cannot assign anyone: its database user can only write to its own table.
An earlier PHP attempt at the same shadow taught me what not to do. Its job ran before the order's transaction had committed, so it often could not see the order. And because riders accepted through a bulk update that skipped model events, most real assignments never reached its records. A shadow that cannot see the outcome cannot be scored. The Go version polls the database for both the trigger and the outcome, so it sees every path that assigns a rider.
What I would do differently, and what is next
I would make freshness part of the index's contract from the start, with explicit removal when a rider goes offline, instead of relying on lazy pruning. I would also put live state in its own Redis from day one.
Next:
- Move the shadow dispatcher toward assisting staff with ranked suggestions, then toward real targeted offers. That only happens once its picks, compared against real outcomes, are clearly better than broadcast.
- Bring the customer app's glide and the web map's dead reckoning closer together, so both use one shared, tested model of motion.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.