The Live Map Problem: Why GPS Pings Shouldn't Touch Your Database First
March 5, 2026 · 6 min read
The first time you build live tracking, you do the obvious thing: every GPS ping gets written to your primary database, and you query that same table for the live map and for "who is nearby this drop-off?" It works beautifully with ten moving units in a demo, which is exactly why it is a trap. The design that passes the demo is the one that fails in production.
Then you have a few hundred units, each pinging every couple of seconds. Suddenly you are doing hundreds of writes a second to a table you are also trying to read from constantly. Writes back up. The geospatial query that powers "nearby" gets slow because it is competing for the same locks and indexes. The live map lags behind reality, which on a delivery product means a customer watching a rider sit motionless on a road they left two minutes ago. Your database is being asked to do two jobs that pull in opposite directions, absorb a firehose of writes and serve low-latency reads, and it loses at both.
Split the fast path from the durable path
The fix is to stop making one system do both. A location update has two completely different jobs, and they deserve different infrastructure. One job is "show this on the map right now and answer nearby queries," which wants speed and can tolerate being slightly lossy. The other is "remember where this thing was," which wants durability and can tolerate being slightly slow. Conflating them is the original sin, and separating them is most of the solution.
On the fast path, each ping lands in a Redis geospatial index with a short TTL. Redis answers "everything within X kilometres of this point" in microseconds, straight from memory, and the TTL means stale positions evict themselves so you do not accumulate ghosts on the map. From there the update fans out to subscribed clients over WebSockets. The detail that matters at scale is the Redis adapter for Socket.io: without it, a client connected to server instance A never hears about an update that arrived at instance B. The adapter puts every instance on one pub/sub backbone, so horizontal scaling does not silently break your real-time layer the day you add a second server.
On the durable path, the same ping is published to a message queue, and an async consumer drains it into the database on its own schedule, batching writes and smoothing spikes. The live experience never blocks on a write, and a flood of writes never slows down the map. The two paths fail independently, which is the whole benefit.
What "nearby" actually needs
It is worth being precise about the query you are really serving, because it shapes everything. "Nearby" is rarely just distance. It is distance plus freshness (a rider who pinged forty seconds ago is a candidate; one last seen twenty minutes ago is not), and often plus a state filter (only available riders, only this vehicle class). Redis handles the radius search, the TTL handles freshness for free because anything stale has already evicted itself, and you keep the small, fast-changing state you filter on in Redis too, rather than joining back to the database on the hot path. Designing the index around the actual question is what keeps the fast path genuinely fast.
You do not have to persist every ping
A firehose tempts you into storing everything, but most products do not need a row for every two-second ping kept forever. Decide the durable contract explicitly. Often it is enough to persist a downsampled track (one point every N seconds, or every M metres of movement) plus the events that actually matter (picked up, delivered, went off-shift). The consumer is the natural place to do that thinning, so the queue absorbs the full rate while the database only ever sees what you have decided is worth keeping. This single decision is the difference between a storage bill that scales with fleet size and one that scales with the square of your ambitions.
Failure handling is the real work
Decoupling only buys you resilience if you design for the failures. If the consumer falls over, the queue buffers messages instead of dropping them, and the consumer catches up when it recovers, so you lose durability latency, not data. A message that cannot be processed, malformed or referencing a deleted entity, goes to a dead-letter queue for inspection instead of wedging the pipeline behind it. And you treat Redis as what it is, an ephemeral cache, fast but disposable, while the database remains the eventual source of truth. If Redis is wiped, the map goes briefly blank and refills on the next round of pings. Nothing is permanently lost.
Make the persistence step idempotent while you are at it, keyed on device plus timestamp, so a redelivered message cannot create duplicate history. And resolve ordering explicitly: pings arrive out of order more often than you would think, so the "current" position is the one with the latest device timestamp, not the latest one you happened to receive over a flaky mobile connection.
Watch the seams
The interesting failures in this design live between the components, so that is what you instrument. The number that tells you the most is consumer lag: the gap between now and the timestamp of the message you are currently persisting. When it grows, your durable history is falling behind, and it is the earliest warning that the consumer is undersized or stuck. You also watch queue depth and dead-letter rate, and you alert on both. And you test the whole flow against real Redis, queue, and database containers rather than mocks, because the bugs that actually hurt, the adapter misconfigured, the consumer lagging, messages dead-lettering, only show up when the real pieces talk to each other.
The tradeoffs
You pay for this. Your persisted history is now eventually consistent, the database trailing real time by however long the queue takes to drain, so anything that needs the absolute latest position reads Redis and anything that needs the durable record reads the database, and you stay clear about which is which. You have also added two pieces of infrastructure to run, monitor, and reason about. In exchange, you get a live map that stays live under load and a write path that cannot take the product down. If you outgrow this shape, it maps cleanly onto dedicated stream processors and purpose-built geo stores, but the principle, separating the fast read-and-broadcast path from the durable write path, does not change.
Enjoyed this? Let me know