Designing a Real-Time Geolocation System: Spatial Indexing, Proximity Queries, and Location Streaming at Scale
A deep-dive into building production-grade real-time geolocation systems covering spatial indexing with geohashes and R-trees, proximity queries, PostGIS vs Redis geospatial, WebSocket location streaming, geofencing, update storm handling, and horizontal scaling strategies.
Location-aware systems look deceptively simple from the outside: record a coordinate, find the nearest thing to it, draw a circle on a map. The complexity surface only reveals itself under load. A ride-sharing platform with 100,000 active drivers updating location every 4 seconds generates 25,000 writes per second before a single passenger opens the app. A delivery network covering 50 cities with geofenced zones triggers millions of boundary checks per hour. A fleet management system must stream positions to multiple subscribers per vehicle while maintaining sub-second freshness.
The design decisions that separate a working prototype from a system that handles these conditions come down to four problem areas: how you index spatial data, how you answer proximity queries efficiently, how you move location events through your infrastructure in real time, and how you scale each layer independently. This article covers all four.
The Spatial Indexing Problem
A naive location store is a table with latitude and longitude columns. Finding all drivers within 5km of a point requires scanning every row and computing the Haversine distance for each. At 100K rows, this is tolerable. At 10M rows with 25K writes per second, it is not.
Spatial indexes solve this by reducing the candidate set before distance computation. The two approaches you will encounter in practice are geohashes and R-trees.
Geohashes
A geohash encodes a coordinate pair as a short string. The world gets divided into a grid; each cell in the grid has a prefix, and cells within that prefix are guaranteed to be geographically nearby. Precision increases with string length: a 6-character geohash covers roughly a 1.2km x 0.6km cell; 8 characters gives you ~38m x 19m.
function encodeGeohash(lat: number, lon: number, precision: number = 6): string {
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
let minLat = -90, maxLat = 90;
let minLon = -180, maxLon = 180;
let hash = "";
let bit = 0;
let charIdx = 0;
let isEven = true;
while (hash.length < precision) {
if (isEven) {
const mid = (minLon + maxLon) / 2;
if (lon >= mid) { charIdx = (charIdx << 1) | 1; minLon = mid; }
else { charIdx = charIdx << 1; maxLon = mid; }
} else {
const mid = (minLat + maxLat) / 2;
if (lat >= mid) { charIdx = (charIdx << 1) | 1; minLat = mid; }
else { charIdx = charIdx << 1; maxLat = mid; }
}
isEven = !isEven;
if (++bit === 5) {
hash += BASE32[charIdx];
bit = 0;
charIdx = 0;
}
}
return hash;
}
function getNeighborGeohashes(geohash: string): string[] {
// Returns the 8 adjacent cells plus the center cell
// This handles the edge-of-cell problem where nearby points
// can have different prefixes despite being geographically close
const neighbors: string[] = [geohash];
// neighbor expansion logic omitted for brevity — use a library
// like ngeohash or compute directly using the known grid offsets
return neighbors;
}
The critical edge case: two points on either side of a geohash boundary can be 1 meter apart but have completely different hashes. Any proximity query using geohash prefix matching alone will miss cross-boundary neighbors. The fix is to always query the target cell and its 8 neighbors. This is well-understood but often skipped in prototype implementations.
R-Trees
R-trees store spatial data in a balanced tree where each node represents a minimum bounding rectangle (MBR) enclosing its children. Proximity queries traverse the tree, pruning branches whose MBR cannot contain the nearest points.
R-trees outperform geohashes for non-uniform data distributions and irregular shapes, but they are harder to update under high write throughput because insertions and deletions can cause tree rebalancing. For high-write location tracking (thousands of updates per second), geohashes on a Redis sorted set or a PostGIS index are typically more operationally tractable than a pure R-tree in your application layer.
PostGIS uses GiST indexes internally, which are R-tree-like structures. When you run a ST_DWithin query against a PostGIS table with a geometry index, you are getting R-tree behavior without implementing it yourself.
Proximity Queries: Find the Nearest N Within a Radius
Three implementation paths, each with different tradeoffs:
Redis GEORADIUS / GEOSEARCH: Redis stores geospatial data internally as a sorted set keyed by geohash. GEOSEARCH key FROMLONLAT lon lat BYRADIUS 5 km ASC COUNT 10 returns the 10 nearest members within 5km. This is fast (reads are O(N+log(M)) where M is the set size and N is results returned), horizontally partitionable by key, and does not require a separate database. The limitation is that Redis does not natively support additional filters on other attributes alongside geo proximity.
PostGIS ST_DWithin: For queries that combine spatial filtering with relational data (find the 10 nearest available drivers who speak Spanish and have a rating above 4.5), PostGIS is the right tool. The query planner uses the spatial index to reduce candidates before applying other predicates.
SELECT
d.id,
d.name,
ST_Distance(d.location::geography, ST_MakePoint($1, $2)::geography) AS distance_m
FROM drivers d
WHERE
d.is_available = true
AND ST_DWithin(
d.location::geography,
ST_MakePoint($1, $2)::geography,
$3 -- radius in meters
)
ORDER BY distance_m ASC
LIMIT 10;
The ::geography cast forces great-circle distance computation rather than planar distance, which matters for accuracy at larger radii. The index must be on the geography type or you lose the speedup.
Hybrid approach for high-write systems: Write location updates to Redis for fast proximity reads; asynchronously sync to PostGIS for historical queries, analytics, and complex filters. This decouples write throughput from read complexity.
interface LocationUpdate {
entityId: string;
latitude: number;
longitude: number;
timestamp: number;
metadata?: Record<string, string>;
}
async function updateLocation(update: LocationUpdate): Promise<void> {
// Fast path: update Redis geospatial index (sub-millisecond)
await redis.geoadd(
"active_drivers",
update.longitude,
update.latitude,
update.entityId
);
// Async path: persist to PostGIS for history and complex queries
await locationQueue.push({
type: "location_update",
payload: update
});
}
async function findNearbyDrivers(
latitude: number,
longitude: number,
radiusMeters: number,
limit: number
): Promise<string[]> {
const results = await redis.geosearch(
"active_drivers",
"FROMLONLAT", longitude, latitude,
"BYRADIUS", radiusMeters / 1000, "km",
"ASC",
"COUNT", limit
);
return results as string[];
}
Real-Time Location Streaming
Location updates from mobile clients arrive continuously. Two questions determine the streaming architecture: how do clients push updates to the server, and how does the server fan out those updates to subscribers?
Client to Server: Update Ingestion
HTTP polling is the wrong approach above trivial scale. Each poll establishes a connection, incurs TCP and TLS handshake overhead, and returns nothing 90% of the time if the client has not moved. WebSocket or HTTP/2 persistent connections eliminate this.
For location ingestion at scale, a UDP-based approach (used by some fleet management providers) reduces latency and avoids head-of-line blocking, but it trades reliability. Most production systems use WebSocket for bidirectional streaming because loss of a few location updates is acceptable and the client can maintain a single persistent connection.
import { WebSocketServer, WebSocket } from "ws";
interface ClientMessage {
type: "location_update";
latitude: number;
longitude: number;
accuracy: number;
timestamp: number;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws: WebSocket, req) => {
const entityId = extractEntityId(req); // from auth header or query param
ws.on("message", async (data) => {
let msg: ClientMessage;
try {
msg = JSON.parse(data.toString());
} catch {
return; // drop malformed messages
}
if (msg.type === "location_update") {
// Validate before writing — untrusted client input
if (!isValidCoordinate(msg.latitude, msg.longitude)) return;
if (msg.accuracy > 100) return; // ignore low-accuracy updates
await updateLocation({
entityId,
latitude: msg.latitude,
longitude: msg.longitude,
timestamp: msg.timestamp ?? Date.now(),
});
await checkGeofences(entityId, msg.latitude, msg.longitude);
}
});
ws.on("close", () => {
removeFromActiveIndex(entityId);
});
});
Server to Subscriber: Fan-Out
A subscriber watching a driver’s location (a passenger tracking their ride, a dispatcher watching a fleet) needs updates pushed to them as they arrive. The naive implementation couples the driver’s WebSocket connection directly to subscriber connections. This breaks as soon as you have multiple server instances, because a subscriber on server B cannot receive events that only exist on server A’s in-memory connection map.
The standard fix: use a pub/sub layer (Redis Pub/Sub or a message broker) as the fan-out mechanism. Each server subscribes to channels for the entities whose subscribers it hosts.
// On location update arrival:
async function publishLocationUpdate(update: LocationUpdate): Promise<void> {
const channel = `location:${update.entityId}`;
await redis.publish(channel, JSON.stringify({
lat: update.latitude,
lon: update.longitude,
ts: update.timestamp,
}));
}
// On subscriber connection:
function subscribeToEntity(entityId: string, ws: WebSocket): void {
const channel = `location:${entityId}`;
const subscriber = redis.duplicate();
subscriber.subscribe(channel);
subscriber.on("message", (_channel, message) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
} else {
subscriber.unsubscribe(channel);
subscriber.quit();
}
});
}
Geofencing
A geofence is a boundary that triggers an event when an entity crosses it. The naive check: on every location update, test if the entity is inside or outside each polygon. For 100K updates per second against 50K geofence definitions, this is millions of point-in-polygon tests per second.
Practical approaches reduce the candidate set before the expensive test:
- Store geofences in a spatial index (PostGIS or Redis). On location update, first query which geofences are within the bounding box of the update. Then run point-in-polygon only against those candidates.
- Pre-assign geofences to geohash cells. When an entity updates its location, only test geofences that overlap the entity’s current geohash cell and its neighbors.
- Maintain previous-state per entity. Track whether the entity was inside each relevant geofence on the last update. Only emit an event if the state changed (entered or exited). This prevents re-firing the same event on every update from a stationary entity.
interface GeofenceState {
entityId: string;
geofenceId: string;
inside: boolean;
lastChecked: number;
}
async function checkGeofences(
entityId: string,
latitude: number,
longitude: number
): Promise<void> {
// Get candidate geofences from spatial index
const candidates = await getNearbyGeofences(latitude, longitude);
for (const geofence of candidates) {
const isInside = pointInPolygon(
[longitude, latitude],
geofence.coordinates
);
const stateKey = `geofence_state:${entityId}:${geofence.id}`;
const previous = await redis.get(stateKey);
const wasInside = previous === "1";
if (isInside !== wasInside) {
await redis.set(stateKey, isInside ? "1" : "0");
await emitGeofenceEvent({
entityId,
geofenceId: geofence.id,
event: isInside ? "entered" : "exited",
timestamp: Date.now(),
});
}
}
}
Handling Location Update Storms
When a fleet of 50,000 vehicles all come online at shift start, or when a music festival ends and 100,000 phones all request rides simultaneously, the location update rate spikes. Systems that handle steady-state throughput can fail at these inflection points.
Three mitigations:
Rate limiting per client: A mobile app sending updates at 1Hz is often sending more than needed. Apply server-side rate limiting to drop or queue updates beyond a threshold per entity. The client does not need to know; you simply process the most recent coordinate per entity within each time window.
Dead reckoning at the server: If you know a vehicle’s last position, speed, and heading, you can predict its current position. This allows you to serve stale-but-estimated data to subscribers during high-load periods without dropping accuracy below acceptable thresholds for most use cases.
Backpressure from the write path: Use a queue between the WebSocket ingestion tier and the actual storage writes. This decouples connection handling (which must be low-latency) from persistence (which can tolerate some lag). Size your queue to absorb spike traffic; set a TTL so stale location updates are discarded rather than processed after the spike passes.
// Deduplicate rapid updates per entity before writing
const updateBuffer = new Map<string, LocationUpdate>();
setInterval(async () => {
const batch = [...updateBuffer.entries()];
updateBuffer.clear();
await Promise.all(
batch.map(([_id, update]) => updateLocation(update))
);
}, 250); // flush every 250ms
function bufferLocationUpdate(update: LocationUpdate): void {
// Most recent update wins — older position for same entity is irrelevant
updateBuffer.set(update.entityId, update);
}
Accuracy vs Battery: Mobile Client Tradeoffs
The GPS receiver on a mobile device draws roughly 150-300mW continuously. At 1Hz update intervals with high accuracy mode, a passenger-carrying driver will drain their battery well before a long shift ends. Battery management is a product concern with architectural implications.
Adaptive polling: Increase update frequency when the entity is moving fast; decrease it when stationary. A vehicle traveling 80km/h changes its position significantly every second. A driver waiting for a passenger at a rest stop does not. The client can use accelerometer or speed thresholds to switch modes.
Fused location (platform APIs): iOS Core Location and Android Fused Location Provider combine GPS, WiFi positioning, and cell tower data to provide location estimates at lower power draw. Accuracy degrades slightly (typically 5-30m versus 3-5m for GPS-only), but for most fleet and ride-sharing use cases this is acceptable.
Server-side staleness tolerance: Design your proximity queries to tolerate location data that is a few seconds old. A driver’s position from 3 seconds ago is close enough for most matching decisions. This allows you to reduce client update frequency to 0.25-0.5Hz for stationary entities without breaking the product experience.
Horizontal Scaling
The location system has three independently scalable tiers: ingestion (WebSocket connections), spatial indexing (reads and writes), and fan-out (subscriber delivery).
Ingestion tier: WebSocket servers are stateful because they hold persistent connections. Scale horizontally behind a Layer 4 load balancer with sticky sessions per entity (not per user session). Each server handles a shard of entities; the pub/sub layer handles cross-server fan-out. Keep connection count per server conservative (around 10K-20K) to avoid memory pressure from per-connection buffers.
Spatial index tier: Redis Cluster shards geo sets by key. Partition your geo index by region or entity type to avoid hot keys. PostGIS scales vertically to a point, then requires read replicas for query fan-out and potentially table partitioning for historical data (partition by time range, not geography, since time-range scans are the most common access pattern for history).
Fan-out tier: Redis Pub/Sub does not scale beyond a single instance for very high subscriber counts because every published message is broadcast to all subscribers on that instance. At very high fan-out ratios, use a message broker with consumer group support, or implement a hierarchical fan-out where a single Redis subscription on each application server fans out to local WebSocket connections via in-memory dispatch.
Tradeoffs Summary
| Dimension | Simple approach | Robust approach | When to upgrade |
|---|---|---|---|
| Spatial index | PostGIS only | Redis for hot path, PostGIS for history | Write throughput above ~5K/s |
| Proximity query | PostGIS ST_DWithin | Redis GEOSEARCH for simple, PostGIS for filtered | When you need attribute filters on spatial results |
| Location streaming | HTTP polling | WebSocket with Redis pub/sub | Any real-time requirement |
| Geofencing | Check all geofences per update | Spatial index + state diffing | Above ~1K geofence definitions |
| Update storm | Accept all updates | Client-side adaptive rate + server-side dedup buffer | Any mobile fleet above 10K devices |
| Scaling | Single server | Sharded ingestion + read replicas | Above 10K concurrent connections |
Production Considerations
Four issues that appear late and are expensive to fix:
Coordinate storage precision: FLOAT(4) latitude/longitude gives you about 1.1m precision at the equator. DOUBLE(8) gives 1.1mm. Use double precision. The storage cost difference is negligible; the accuracy cost of float is not.
Clock skew on mobile clients: Device clocks drift. A location update timestamped 5 minutes in the future is not necessarily fraudulent, but it will corrupt time-ordered queries. Accept client timestamps with a sanity window (reject if more than 30 seconds ahead of server time) and always store server receipt time alongside client timestamp.
Index maintenance under high write load: PostGIS GiST indexes lock rows during updates. Under heavy concurrent write load this creates contention. Use CREATE INDEX CONCURRENTLY for index builds and consider BRIN indexes for append-only historical tables where range scans dominate.
Privacy and data retention: Raw location history is sensitive. Define retention policies before launch, not after. Aggregate or anonymize historical data beyond the operational window (typically 30-90 days). Ensure your storage design supports efficient time-range deletes, which means partitioning your history table by timestamp.
The Core Insight
Real-time geolocation systems fail in one of two ways: they cannot keep up with write throughput, or their proximity query latency degrades as data grows. Both failures have the same root cause: treating location data like ordinary relational data. It is not. Coordinates require spatial indexes, proximity queries require geometry-aware operators, and high-frequency updates require write paths designed around deduplication and batching rather than transaction integrity. Build the data model around those constraints first, then add the operational concerns around it.
The read path and write path in a location system have almost nothing in common. Design them separately from the start.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.