Storing and querying locations
Open in the interactive app: practice, quiz, mock interview →In one minute
- Location questions come in two flavours: find nearby (businesses, drivers, people within a radius) and track a moving object (a driver approaching, a courier en route). The first is a spatial index problem; the second is a high-rate write and fan-out problem.
- A plain B-tree on latitude and longitude cannot answer "within 2 km" efficiently, because it orders by one dimension. Spatial indexes map 2D space to cells: geohash and S2 use space-filling curves, quadtrees split adaptively, R-trees group bounding boxes, and H3 uses hexagons.
- Every cell scheme has edges, so a radius query covers the target cell plus its neighbours, then filters candidates by exact distance.
- Static places (restaurants) belong in a persistent spatial index; constantly moving objects (drivers) belong in an in-memory grid keyed by cell, with stale entries expiring.
- Tracking pushes position updates to subscribers over long-lived connections, partitioned by city or trip so no single server sees all traffic.
How it works
When the pattern shows up in interviews
"Design Uber/Lyft", "Design Yelp or Google Places", "Design Tinder" (people near me), "Design DoorDash tracking", "Find My Friends", and geofencing ("notify me when I arrive"). Interviewers want to hear how you avoid scanning all points, how you handle density differences between a city centre and the countryside, how often locations update, and what happens at cell boundaries.
Geohash: cells as string prefixes
Geohash interleaves bits from repeatedly halving longitude and latitude ranges, then encodes every 5 bits as a base-32 character. Each extra character shrinks the cell; at 6 characters a cell is roughly 1.2 km by 0.6 km (Wikipedia). Points that share a prefix are in the same cell, so any store that supports prefix or range scans (a B-tree, a sorted set, a wide-column store) becomes a spatial index. The weakness is the seam: two points a metre apart on opposite sides of a cell boundary can have completely different prefixes, so queries always include the 8 neighbouring cells.
Quadtrees and R-trees: adaptive structures
A quadtree starts with the whole map as one node and splits a node into four children when it holds more than some threshold of points. Dense downtown areas end up with tiny cells, deserts with huge ones, so each leaf holds a similar number of points. It is usually built in memory and rebuilt or updated incrementally. An R-tree groups nearby objects into bounding rectangles in a balanced tree (Guttman, SIGMOD 1984) and is what PostGIS uses (through a GiST index) to answer ST_DWithin queries. Both shine for static or slowly changing data.
S2 and H3: global cell systems
Google's S2 projects the sphere onto a cube and orders cells along a Hilbert curve, which preserves locality better than geohash's Z-order. A 64-bit cell ID holds 3 face bits, then 2 bits per level down to 30 levels, where leaf cells are about 1 cm across (S2 docs); a "region coverer" returns a small set of cells covering any circle or polygon. Uber's H3 tiles the globe with hexagons at 16 resolutions, from about 1.4 km edges at resolution 7 to 200 m at resolution 9; every neighbour of a hexagon is the same distance away, which makes it convenient for "ring of cells around me" queries and for aggregating demand and supply into cells for pricing. A sphere cannot be tiled with hexagons alone, so each resolution also has exactly 12 pentagons, with five neighbours, that ring code must tolerate (H3 docs). Older tutorials use the version 3 API: geoToH3 is now latLngToCell and kRing is now gridDisk (H3 docs).
Moving objects
Drivers send a location every few seconds. Writing each update into a disk-based spatial index would churn it, so keep a separate in-memory structure: a map from cell ID to the set of driver IDs in that cell, plus a map from driver to its last position and cell. On each update, if the cell changed, move the driver between sets. Entries carry a last-seen time and drop out when stale. Redis's GEOADD and GEOSEARCH implement exactly this on top of a sorted set; GEORADIUS, still common in older answers, has been deprecated since Redis 6.2 (Redis docs). Partition by city or by coarse cell so one shard handles one region.
Tracking and fan-out
When a trip starts, the rider subscribes to the driver's updates. The driver's app sends positions over a persistent connection to a gateway; the location service publishes them on a channel keyed by trip ID; the rider's gateway pushes them down a WebSocket. Clients interpolate between updates to animate smoothly, so a 4-second update interval looks continuous.
Down the stack
Layer 1The contract
A radius query returns every indexed object whose last reported position is within the radius, sorted by distance, as of a staleness bound equal to the update interval plus pipeline delay. Positions older than a TTL are excluded. A nearest-k query returns the k closest, which may require widening the search ring when nearby cells are empty. Tracking delivers updates in order per object with best-effort latency; a dropped update is superseded by the next, so it needs no retry.
Layer 2The mechanism
Geohash encoding alternates bits: bit 0 halves longitude, bit 1 halves latitude, and so on; 5 bits map to one character of 0123456789bcdefghjkmnpqrstuvwxyz. Redis stores members of a geo set in a sorted set whose score is a 52-bit interleaved geohash integer, which a double holds exactly; because it uses the Web Mercator bounds, latitudes beyond about 85.05 degrees are rejected (Redis docs). GEOSEARCH ... BYRADIUS estimates the geohash precision whose cell size fits the radius, computes the centre cell and its 8 neighbours, issues a score range lookup for each in the sorted set's skip list, then computes exact distance for each candidate and discards those outside the circle. PostGIS builds a GiST index of bounding boxes; ST_DWithin(geog, point, 2000) expands the point into a box for the index scan, then rechecks exact distance on the candidates, measured in metres on the spheroid by default (PostGIS docs). A quadtree node holds either up to N points or four children, and a query descends only into children whose bounds intersect the search circle.
Layer 3The machine
A location update is small: an ID, two float64 coordinates (16 bytes), a timestamp and maybe heading and speed, under 100 bytes on the wire after framing. A million active drivers reporting every 4 seconds is 250,000 updates per second, which is a lot of requests but only tens of MB per second of payload and around 100 MB of state in memory. The cost is in connections and syscalls, not bytes, so gateways batch writes and keep long-lived TCP or WebSocket connections, each costing kilobytes of kernel and user-space buffers. Exact distance uses the haversine formula, a handful of trigonometric calls costing on the order of tens to a hundred nanoseconds, so filtering a few thousand candidates is cheap; scanning millions is not. Haversine assumes a spherical Earth, so it can be off by up to 0.5%, fine for "drivers near me" but not for surveying (the same Redis page). Phone GPS is typically accurate to about 4.9 m under open sky and worse near buildings, bridges and trees (GPS.gov), so precision beyond that is noise; snapping to roads happens server-side.
Why it's built this way
- Databases index well in one dimension, so 2D positions are mapped onto a 1D curve (geohash, S2) or a tree of rectangles.
- Curves have seams, so queries cover neighbouring cells and then filter by exact distance.
- Density varies by orders of magnitude between places, so adaptive structures or multiple resolutions keep cells balanced.
- Moving objects write constantly and only the latest position matters, so they live in memory with TTLs rather than in a durable spatial index.
- One region's traffic is independent of another's, so shard by geography and keep fan-out local to the trip.
Diagram
flowchart LR
D["Driver app (every 4 s)"] --> GW["Connection gateway"]
GW --> LS["Location service (city shard)"]
LS --> GRID[("In-memory grid: cell -> driver IDs")]
LS --> PS["Pub/sub channel per trip"]
PS --> RG["Rider gateway"]
RG --> R["Rider app (WebSocket)"]
M["Matching service"] -->|"cells covering 2 km"| GRID
GRID -->|"candidates"| M
M --> F["Exact distance filter + ETA ranking"]Worked example
A ride-hailing company serves one large metro with up to 60,000 online drivers at peak. Each sends a position every 4 seconds: 15,000 updates per second for the city. The location service keeps a map of geohash-6 cells (about 1.2 by 0.6 km) to driver sets, and a driver map of last position, cell and timestamp. State is about 60,000 times 100 bytes, roughly 6 MB, so one process could hold it; the team runs three replicas fed from the same update stream for availability.
Riders request about 400 trips per second at peak. For each, matching looks up the rider's cell and its 8 neighbours, which covers at least 0.6 km in every direction and typically returns 50 to 200 drivers downtown. It drops entries older than 15 seconds, computes haversine distance, keeps the closest 20, and calls the routing service for road ETAs on those only. If fewer than 5 candidates appear, it widens to the next ring of cells. Suburbs work the same way with more rings, which is why some designs choose a coarser precision or an adaptive quadtree in sparse areas.
Once matched, the driver's updates are also published to trip:{id} and pushed to the rider's WebSocket.
In the wild
- Uber, 2016: geofences without a spatial tree. Uber's highest-traffic service answers "which zones contain this point". Rather than an R-tree or S2, it scans city boundaries first and then only that city's fences, cutting candidates from tens of thousands to hundreds. On New Year's Eve 2015 it served 170,000 queries per second on 40 machines at 35% CPU, under 5 ms at p95 (Uber Engineering).
- Redis, 2022: a radius search misses a point inside the radius. Near 85 degrees north, a
GEORADIUSBYMEMBERwith a 4,891.94 m radius missed a member thatGEODISTplaced at 4,891.938 m, because the centre cell plus neighbours did not fully cover the bounding box. The fix shipped in 6.2.7 and 7.0, a reminder that cell covering is an approximation you should test at the edges (Redis PR 10018). - Check Point Research, 2024: location leaks in dating apps. Hornet randomised the distances it showed and let users hide them, yet repeated distance queries from chosen points located users to within about 10 m, even with the display off; after fixes the error grew to about 50 m. Another app sent 12-character geohashes, cells of about 37 by 18 cm, and stayed safe only because it rounded coordinates to three decimals (about 110 m) before encoding: privacy comes from discarding precision, not from the encoding (Check Point Research).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Geohash in a KV or sorted set | Works on any ordered store, simple | Seams, uneven cell sizes by latitude | Most interview answers, Redis GEO |
| Quadtree (in memory) | Adapts to density | Rebuild cost, harder to shard | Static points with very uneven density |
| PostGIS R-tree (GiST) | Exact queries, polygons, SQL joins | Single-node writes, heavy for high update rates | Places, geofences, admin queries |
| S2 cells | Hilbert locality, good region covering | More complex library | Large-scale geo systems, polygons |
| H3 hexagons | Uniform neighbours, great for aggregation | Hexagons do not nest perfectly | Supply and demand heatmaps, pricing |
Pitfalls in production
- Querying only the centre cell. Nearby objects across a seam are missed. Always include neighbours and filter by exact distance.
- Euclidean distance on degrees. A degree of longitude shrinks towards the poles, so rankings skew. Use haversine or a projected coordinate system.
- Stale drivers shown as available. An app crash leaves a ghost entry. Expire positions by last-seen time.
- Hot cells. An airport or stadium cell holds thousands of drivers. Use finer cells there or cap candidates per cell.
- Over-frequent updates. Sending every second drains batteries and quadruples load with little accuracy gain. Adapt the rate to speed and trip state.
Leadership lens
- Separate the static places index from the moving-object index; they have opposite read and write profiles.
- Choose the cell system once and use it across matching, pricing and analytics so data joins cleanly.
- Shard by geography, but plan for trips that cross shard boundaries, such as airport runs between cities.
- Location history is personal data; set retention limits and access controls from the start.
Check yourself
Why can't a composite B-tree index on
(lat, lon)answer a radius query efficiently?Show answer
It orders by latitude first, so a latitude band scan returns every longitude in that band worldwide, and the longitude condition filters afterwards.
Why does a geohash radius query look at neighbouring cells?
Show answer
Points close together can sit on opposite sides of a cell boundary and share no prefix, so the centre cell alone misses them.
Why are moving drivers kept in memory instead of in PostGIS?
Show answer
Each driver writes every few seconds and only the latest position matters, so a durable spatial index would churn while an in-memory cell map handles the rate cheaply.
What makes H3 hexagons attractive for pricing heatmaps?
Show answer
All six neighbours are equidistant from the centre, so rings and smoothing behave uniformly, unlike squares with diagonal neighbours.
Sources and further study
Primary
- Guttman, R-Trees: A Dynamic Index Structure for Spatial Searching (SIGMOD 1984): the original R-tree with its search, insert and node-split algorithms.
Systems docs
- Redis: GEOADD: the 52-bit score, the 1+8 cell query and the spherical Earth model.
- PostGIS: ST_DWithin: index-assisted radius queries on geometry and geography.
- H3: table of cell statistics by resolution: cell areas, edge lengths and the 12 pentagons per resolution.
- H3: 3.x to 4.0 function renames: the old and new API names side by side.
Books
- System Design Interview Vol. 2, Alex Xu and Sahn Lam, chapters 1 "Proximity Service" and 2 "Nearby Friends": geohash, quadtree and S2 compared, then location fan-out over WebSockets and pub/sub.
- Designing Data-Intensive Applications (2nd ed.), Kleppmann and Riccomini, chapter 4 "Storage and Retrieval": multidimensional indexes, including space-filling curves and R-trees.
Other curricula
- Azure Architecture Center: Geode pattern: frames geographic locality at the deployment level, serving users from the nearest of many full copies of the backend.
Watch
- Proximity Search & Geospatial Indexes Explained (Hello Interview): geohash, quadtrees and R-trees framed for interviews.
- H3: Tiling the Earth with Hexagons (Uber Engineering): why Uber built a hexagonal grid and how it is used.
- FAANG System Design Interview: Design A Location Based Service (ByteByteGo): a proximity service walkthrough comparing indexing options.
Free resources
- Geohash (Wikipedia): the encoding, cell sizes per precision and edge cases.
- S2 cell hierarchy (S2 Geometry docs): how S2 maps the sphere to Hilbert-ordered cells.
- H3 documentation (h3geo.org): resolutions, indexing and neighbour operations for hexagonal cells.
- Redis geospatial (Redis docs): how geo sets are stored in sorted sets and queried.
- Spatial indexing (PostGIS workshop): how GiST indexes speed up spatial queries and why the recheck step exists.