Forward-deployed / Learning zone
System Designa standalone module
Lesson 06

Location & geo services

TL;DR

Location-aware systems face a fundamental challenge: mapping the continuous, curved surface of the Earth onto discrete, queryable data structures. Proximity search (find businesses near me) uses geohashing to convert 2D coordinates into sortable 1D strings. This enables efficient range queries on standard databases. Nearby friends (real-time location sharing) adds the streaming dimension — 334K location updates per second flowing through WebSocket servers and Redis pub/sub channels. Google Maps stacks multiple geospatial problems: tiling the planet into a hierarchy of pre-rendered images, routing through continent-scale road graphs, and computing adaptive ETAs from live traffic. The common thread: spatial indexing is the bottleneck. The choice of index structure — geohash, quadtree, S2 cells — determines every downstream tradeoff.

🎯 For the technical PM

Why it matters — Any feature involving "nearby," "around me," or "estimated arrival" depends on spatial indexing. The choice of geospatial data structure determines query latency, update cost, and whether your system can scale to millions of moving entities.

What it changes in your decisions — You stop treating location as just a lat/long column with a distance filter. You budget for the infrastructure to handle continuous location streams (WebSockets, pub/sub), and you size your tile/routing CDN for the coverage area.

Ask your eng team — "Are we using geohash or quadtree for spatial indexing, and how do we handle queries at geohash boundaries?"

Risk if ignored — Your "find nearby" feature scans every record in the database, your real-time location sharing drains phone batteries, or your routing engine gives ETAs that are 30% off because it ignores live traffic.


Proximity service

Scale and requirements

The spatial indexing problem

The naive approach — scan all 200M businesses, compute the distance to the user, filter by radius — is O(n) per query. At 100M DAU, that's unacceptable.

The problem is that standard database indexes (B-trees) work on one dimension. Latitude and longitude are two dimensions. You need a way to convert 2D spatial proximity into something a 1D index can handle.

Approaches compared

Approach How it works Pros Cons
2D range scan WHERE lat BETWEEN x1 AND x2 AND lng BETWEEN y1 AND y2 Simple Two index scans intersected; slow at scale
Even grid Divide world into equal-sized cells Conceptually simple Uneven data distribution (ocean cells empty, city cells overloaded)
Geohash Encode lat/lng into a base-32 string; shared prefix = spatial proximity Sortable, standard DB index, easy neighbor lookup Edge cases at cell boundaries
Quadtree Recursively subdivide space; split cells that exceed a threshold Adaptive density In-memory only; hard to distribute
Google S2 Map sphere to cube faces, then use Hilbert curves Handles poles and antimeridian; tunable cell levels Complex implementation

Geohash is the standard choice for most proximity services: it's simple, works with any database that supports range queries, and handles the common case well.

How geohashing works

Geohash recursively bisects the world, alternating between longitude and latitude, encoding each decision as a bit. The bits are then encoded as a base-32 string:

Geospatial indexing - geohash grid + neighbor lookup
Geohash Recursive subdivision of the map into grid cells
9q8wfar
9q8xneighbor
9q8yneighbor
9q8zfar
9q8tneighbor
9q8vneighbor
9q8syou are here
9q8uneighbor
9q8efar
9q8gneighbor
9q8fneighbor
9q8dfar
9q87
9q86
9q85
9q84
Query cell (your location)
8 neighbors (always queried)
Outside search radius
Precision vs. area:
4 chars = ~39km · 5 chars = ~5km · 6 chars = ~1.2km
More chars = smaller cell = finer search
Query Nearby search pipeline
lat, lng37.77, -122.42
→
Computegeohash(lat,lng,6)
→ "9q8s"
Expandcell + 8 neighbors
→ 9 keys
RedisZRANGEBYSCORE
→
Sortby distance
Geohash Recommended
+ Simple string prefix matching + Easy to store in Redis sorted sets + Incrementally refine precision - Edge cases at cell boundaries
Quadtree
+ Dynamic subdivision by density + Natural spatial indexing - Must rebuild on updates - Harder to distribute

Key properties:

Geohash length Cell width Cell height Use case
4 ~39 km ~20 km Regional search
5 ~5 km ~5 km City district
6 ~1.2 km ~0.6 km Neighborhood
7 ~150 m ~150 m Block level

The boundary problem and 8-neighbor lookup

Geohash has a well-known edge case: two points on opposite sides of a cell boundary can be very close geographically but have completely different geohash prefixes.

The solution: query the target cell and all 8 neighboring cells:

The boundary problem

Query the target cell and all 8 neighbors

A point 50m away across a cell boundary must not be missed.

NW
N
NE
W
Target cell — user's geohash
E
SW
S
SE
9 prefix lookups, still fast on an indexed column — any point within the search radius falls within one of these 9 cells.

This guarantees coverage: any point within the search radius falls within one of these 9 cells (assuming the cell size is chosen to match the search radius). The query becomes 9 prefix lookups — still fast on an indexed column.

Caching with Redis

Business data changes slowly (new restaurant listings, address updates), but searches are extremely frequent. Cache businesses by geohash key in Redis:

A search query resolves to 9 Redis lookups (the target cell + 8 neighbors), each returning a list of business IDs. Client-side or API-side filtering then computes exact distances and applies the radius filter.

Architecture

Proximity service architecture

Redis geohash cache in front of the business database

A change stream keeps the cache in sync as businesses update.

Client
→
Load balancer
→
API servers
Redis cache — geohash → business IDs
Business DB — business details
Background worker — rebuilds cache on business changes, fed by the DB's change stream

Nearby friends

Scale and requirements

Why HTTP polling fails

At 334K updates/sec, if each update requires:

  1. Client sends location to server (HTTP request)
  2. Server queries all friends' locations
  3. Server returns nearby friends list

This is request-heavy, latency-heavy, and wasteful — most updates won't change the nearby-friends list.

WebSocket servers (stateful)

The solution: persistent WebSocket connections between each client and a WebSocket server. The server holds in-memory state mapping user_id to their connection:

Stateful WebSocket servers

Persistent connections replace request-heavy polling

Each server holds in-memory state mapping user_id to their connection.

Server 1
User A connection
User B connection
User C connection
Server 2
User D connection
User E connection
User A's friends may connect to a different server — Server 1 needs a way to notify Server 2 that User A moved.

When User A sends a location update:

  1. The WebSocket server receives it.
  2. It looks up User A's friend list.
  3. For each friend, it checks: is that friend nearby?
  4. If yes, it pushes User A's new location to the friend's WebSocket connection.

The problem: User A's friends may be connected to different WebSocket servers. You need a way for Server 1 to notify Server 2 that User A moved.

Redis pub/sub for cross-server communication

Each user gets a Redis pub/sub channel named after their user_id. When User A comes online:

Redis pub/sub for cross-server communication

Each user gets a channel named after their user_id

User A and User B are friends, connected to different WebSocket servers.

User A — WS Server 1
Redis pub/sub
User B — WS Server 2
1A → Redis: subscribe to channel:user_b
2B → Redis: publish to channel:user_b (lat, lng, ts)
3Redis → A: receive User B's location
4A computes distance, pushes to User A if nearby

Scaling Redis pub/sub

With 10M concurrent users each subscribing to ~400 friend channels, the total subscription count is 4 billion. A single Redis instance cannot handle this.

Consistent hashing distributes users across a Redis pub/sub cluster:

Redis location cache

Separate from the pub/sub layer, a Redis location cache stores the latest known position of every active user:

This cache serves two purposes:

  1. When a user first comes online, it bootstraps their nearby-friends list without waiting for individual friend updates.
  2. It provides a fallback when pub/sub messages are lost.

Alternative: Erlang distributed processes

An alternative architecture uses Erlang/Elixir (or similar actor-model systems) where each user is a lightweight process:

This eliminates the Redis layer but couples you to the Erlang ecosystem. WhatsApp famously used this approach to handle 2M connections per server.


Google Maps

Scale and requirements

Web Mercator projection

Maps display a spherical planet on a flat screen. Web Mercator (EPSG:3857) projects the sphere onto a cylinder, then unrolls it:

The projection distorts area near the poles (Greenland looks enormous) but preserves angles and shapes locally, which is what navigation needs.

Geohashing for tile addressing

Each tile is addressed by (zoom_level, x, y), but the system uses geohashing principles for spatial indexing:

Static pre-rendered tiles + CDN

Map tiles are pre-rendered at all zoom levels and served as static images from a CDN:

Static pre-rendered tiles + CDN

Tile requests are the dominant read pattern — pre-render and cache aggressively

Most users look at a few cities; those tiles are always cached at the edge.

Client — tile request: zoom/x/y.png
→
CDN edge server
Cache hit→ Client
Cache miss→ Tile render service → Map data store

At Google's scale, the tile set is hundreds of petabytes. But the access pattern is highly skewed:

Hierarchical routing tiles

Road network routing at global scale cannot use a single graph — the full graph has billions of nodes. Instead, the road network is partitioned into routing tiles at multiple hierarchy levels:

Hierarchical routing tiles

Billions of nodes reduced to thousands per query

Local roads for the last mile, highways for the middle, local roads again at the destination.

Level 0: Local roads — small tiles, full detail
↓ connect at tile boundaries ↓
Level 1: Arterial roads — medium tiles, major roads only
↓ connect at tile boundaries ↓
Level 2: Highways — large tiles, highways and expressways

For a cross-country route:

  1. Route locally from origin to the nearest highway (Level 0).
  2. Route on highways to near the destination (Level 2).
  3. Route locally from highway to destination (Level 0).

This hierarchical decomposition reduces the search space from billions of nodes to thousands per query.

A* shortest path

Within each routing tile, the system uses A* search — a variant of Dijkstra's algorithm that uses a heuristic (straight-line distance to destination) to prioritize exploring nodes in the direction of the goal:

Client-side GPS batching

Mobile clients send GPS coordinates, but not every reading:

Adaptive ETA via tile-based traffic tracking

Real-time ETA depends on current traffic conditions, computed from the aggregate behavior of all map users:

  1. Every active navigation client reports its (tile, speed, timestamp) via GPS batches.
  2. A traffic processing service aggregates speeds per road segment per time window.
  3. The aggregated speeds update edge weights in the routing graph.
  4. ETA computation for new routes uses these live-updated weights.
Adaptive ETA via traffic tracking

The more users navigate, the more accurate the ETAs

GPS batches feed a live-updated routing graph.

Millions of navigation clients — GPS batches
→
Ingestion service
→
Kafka / stream processor
→
Traffic aggregation — per segment, per 5-min window
↓ updates edge weights → Routing graph → ETA computation for new route requests

The feedback loop is powerful: the more users navigate with the system, the better the traffic data, the more accurate the ETAs, the more users trust and use the system.


Failure modes

Practitioner checklist