Every FSM article about live GPS reaches for Kafka, Redis Streams or some other shiny pipe. We tried two of those and threw them out. What runs in production at FieldServo is plain Postgres, a WebSocket fan-out, and a geohash index. Here is how.
The problem we needed to solve
Tenant dispatchers want a city-wide map showing every active truck, with a refresh frequency that feels live (≤1 second). Our biggest tenants have ~3,500 active technicians at peak; across the platform we sustain ~10,000 concurrent connections. Each technician's mobile app emits a position every 5 seconds.
That is roughly 2,000 writes per second sustained, with read fan-out to every dispatcher subscribed to that tenant's map. The naive architecture (mobile → API → row insert; dispatcher polls every second) collapses around 800 writes/sec on a single Postgres node.
The protocol on the wire
Three rules:
- The mobile app sends position updates over a persistent WebSocket, not REST
- The server never writes every update — only every Nth update or when delta exceeds 50m
- The dispatcher map subscribes to a topic
map:{tenantId}:{geohash}and gets pushed only positions inside that bucket
// Mobile app
ws.send({ type: "pos", lat, lng, ts, accuracy })
// Server (Node + uWebSockets)
if (shouldPersist(prev, current)) {
await db.exec("UPDATE technicians SET ... WHERE id=$1", [techId])
}
broadcast("map:" + tenantId + ":" + geohash6(current), current)
Why geohash buckets
A geohash is a string encoding of lat/lng where the length of the string determines the precision. At length 6, each cell is approximately 1.2km × 0.6km. At length 7, it shrinks to ~150m. We use length 6 for map subscriptions: a dispatcher viewing the city of Portland subscribes to roughly 80 buckets, which fan out a manageable volume of events.
The killer feature: a moving technician usually stays in the same bucket for several updates. We do not have to re-route the subscription model when they cross a boundary — they just begin appearing in a new bucket's stream.
Geohash gotcha
Geohash boundaries can be far apart in geohash-string space but adjacent in physical space. We pre-compute neighbour lists at startup and store them in Redis — lookup is O(1).
Postgres carries more than you think
People reach for Kafka or Timescale early. We did. They were operational drags and added latency to a problem that did not need a queue. Postgres with a GIST index on a point column, write batching at the application layer (8-row INSERTs every 50ms), and an UNLOGGED table for ephemeral positions held the load to about 18% CPU on a 4-core RDS instance.
If we ever hit 50k concurrent technicians we will revisit. Today, the answer is "yes, even Postgres works".
The mobile-side compromise
Battery is the constraint nobody talks about. A 5-second GPS poll murders battery; a 30-second poll feels stale. Our app dynamically adjusts the poll interval based on speed: stationary → 30s, walking → 15s, driving → 5s. The dispatcher map looks live because driving is the only state where staleness is visible.
Numbers from production
- p50 end-to-end latency from mobile emit to dispatcher render: 410ms
- p99 end-to-end latency: 1.2s
- Sustained writes/sec: 2,000 (peak 3,400)
- Concurrent WebSocket connections: ~10,000
- Postgres CPU: ~18% at sustained load
Mistakes we made
We over-engineered for two years. We tried Kafka, then Redis Streams, then a custom geo-sharded service in Go. All of them solved problems we did not have and added problems we did. The current architecture is the third rewrite, and it is the simplest of the three.
The pattern repeats: when a system feels slow, the instinct is to add infrastructure. The correct instinct is to subtract requirements.
Pick the boring tool. Run it for six months. Re-evaluate. Tomás Silva