For a ride-hailing platform I worked on, thousands of drivers send their location every few seconds. Riders watch their driver approach on a map, dispatch finds the nearest available drivers in milliseconds, and the database shouldn't melt. Redis does the heavy lifting; Postgres keeps the record.
The shape of the problem
- Writes: one update per driver every ~5 seconds, all day.
- Reads: "who's near this pickup?", "where's my driver right now?"
- Fan-out: every update should reach the riders watching that driver.
Writing each update to a relational database and querying it for nearby drivers works for a demo and falls over in production.
One update, one atomic round trip
Each location update does four things in a single Redis MULTI:
const multi = redis.multi();
multi.geoadd('driver_locations', lng, lat, driverId); // 1. position, for radius queries
multi.hset(`driver:meta:${driverId}`, { heading, speed, at }); // 2. metadata
for (const res of [6, 7, 8, 9, 10, 11]) { // 3. hexagonal cells (H3)
multi.srem(`h3:${res}:${oldCell[res]}`, driverId);
multi.sadd(`h3:${res}:${newCell[res]}`, driverId);
}
multi.publish('driver_location_updates', payload); // 4. tell subscribers
await multi.exec();One network round trip, applied together, so readers never see a driver's new position with old metadata.
Finding nearby drivers
Radius and box queries come straight from Redis's geo commands, which are backed by a sorted set:
// Drivers within 3 km of a pickup, nearest first
await redis.geosearch('driver_locations', 'FROMLONLAT', lng, lat, 'BYRADIUS', 3000, 'm', 'ASC', 'WITHDIST');
// Drivers visible in the rider's map viewport
await redis.geosearch('driver_locations', 'FROMLONLAT', cLng, cLat, 'BYBOX', width, height, 'm', 'WITHCOORD');Matching uses H3, Uber's hexagonal grid. Each driver is stored in a Redis set per hexagon at several resolutions (6–11), so dispatch can grow the search ring by ring (gridDisk) and change its resolution in config without reindexing a single driver. Pre-storing every resolution also avoids parent-cell quirks across levels.
Real-time fan-out
The PUBLISH at the end of each update feeds the WebSocket gateways. Each gateway subscribes to the channel and forwards updates to the riders in that trip's room. Gateways stay stateless, so they scale horizontally behind a load balancer.
Two small keys make the experience feel instant:
- Last known location (
driver:last_location:{id}, 24 h TTL): when a rider opens the app, they see the car immediately instead of waiting for the next update. - Rolling flags (for example "this driver has a queued trip", 5 min TTL refreshed on use): answers a hot question on every update without hitting the database.
Clean the signal before it hits the map
Raw GPS is noisy, and noise becomes jumpy cars and wrong fares:
- Bad accuracy is dropped, not the update. Readings with an implausible accuracy radius keep their position but lose the accuracy value, so an update never fails because of one bad field.
- Stationary jitter collapses. Points within a small radius of an anchor count as "not moving", unless the device reports real speed.
- Trip distance is snapped to roads before fares are calculated.
Postgres keeps the record
Redis holds the live state; Postgres holds history. Trip traces are batched and written periodically, not per update, and the database is there for audits, disputes and analytics, never on the hot path.
What made it hold up
- Atomic multi-key updates so readers see consistent state.
- The right structure per question: geo sorted set for distance, H3 sets for matching, hashes for metadata, pub/sub for fan-out.
- TTLs everywhere, so stale data cleans itself up.
- Stateless realtime servers, with Redis as the meeting point.