API Design

Design Case Studies · Lesson 09

Design: Maps API

A Maps API serves billions of requests per day yet most of those requests are for the same tiles. The secret isn't raw compute power — it's recognising which parts of the problem are read-only and cache-perfect, and which parts require real computation every time.

⏱ ~17 min Difficulty: advanced Prereqs: REST, caching, rate limiting

By the end you'll be able to

1 · Requirements

An interviewer says: "Design the Maps API that powers a ride-sharing app, a local business directory, and an embedded map widget." Before reaching for endpoints, translate that into concrete, measurable requirements.

RequirementWhat it implies
Geocoding (address → lat/lng)Parse free-text addresses; handle abbreviations, misspellings, partial input
Reverse geocoding (lat/lng → address)Snap coordinates to the nearest street address or point of interest
Turn-by-turn routingShortest/fastest path between two points; respect travel mode (driving, walking, cycling)
Nearby searchFind places of a given type within a radius — restaurants, gas stations, hospitals
Map tile servingRasterised or vector tiles at any zoom level, x/y coordinate
Massive read scaleBillions of tile requests per day; geocoding and routing at tens of thousands of RPS
Low latencyTile: <50 ms p95; geocoding: <200 ms p95; routing: <500 ms p95
High availability99.99% uptime — maps are mission-critical for navigation and emergency services
Per-API-key quota enforcementFree tier: 10,000 requests/day; premium: 1M+; overage billing or hard cutoff

Two requirements that look similar but are architecturally very different: tile serving and geocoding. Tiles are static images that never change once rendered — you cache them once and serve them forever. Geocoding requires a live query against a structured address database and has much lower cache hit rates. Routing requires running a shortest-path algorithm on a graph that changes hourly with traffic data. These three workloads get different infrastructure.

✅ Split the problem early

In an interview, explicitly distinguish the three sub-systems before proposing any architecture. Tile serving = CDN problem. Geocoding = database search + cache problem. Routing = graph algorithm + cache problem. Keeping them separate prevents you from accidentally applying one solution to all three.

2 · Design decisions

2a. Spatial indexing with geohash

Imagine folding a world map in half east-west, then north-south, then east-west again — each fold divides the world into smaller and smaller cells. Geohash does exactly this, encoding the resulting cell as a base-32 string. The string "9q8yy" means: starting from the full world, follow the fifth fold (east), the seventeenth fold (north), the eighth fold (east)... and so on for five characters, landing on a roughly 4.9 km × 4.9 km cell near Mountain View, California.

The critical property: cells that share a long common prefix are geographically close. "9q8ya" and "9q8yb" are neighbours. This makes geohash useful for two things simultaneously:

9q8y7 9q8yk 9q8ys 9q8ye 9q8yy query cell 9q8yz 9q8yv 9q8yw 9q8yx Nearby search logic 1. Compute point's geohash → "9q8yy" 2. Expand to 9 cells (cell + 8 neighbours) 3. Query: geohash IN (9 prefixes)
A geohash grid centred on cell "9q8yy". A nearby search queries the centre cell plus all 8 neighbours to avoid missing records that sit just across a cell boundary.

One edge case matters: a query point near the edge of a cell may be closer to records in a neighbouring cell than to records in the same cell. The fix is always to query the 8 surrounding cells in addition to the cell containing the point. This is cheap — nine prefix scans on an indexed column.

⚠️ Common trap: naive bounding-box SQL

A tempting first approach is WHERE lat BETWEEN 37.40 AND 37.45 AND lng BETWEEN -122.10 AND -122.05. This looks simple but it does a full table scan unless both lat and lng are indexed together with a composite spatial index. Most SQL optimisers will use just one of the two indexes and still scan millions of rows. Geohash prefix matching on a single indexed text column, or a proper PostGIS spatial index using ST_DWithin, avoids this entirely.

2b. Quadtree tiles and the zoom/x/y coordinate system

The tiling system works like a quadtree: at zoom level 0, the entire world is one 256×256 pixel tile. At zoom level 1, that one tile splits into four (2×2 grid). At zoom level 14, there are 214 × 214 = 268 million tiles. Each tile is uniquely identified by {z}/{x}/{y} — zoom, column, row.

The essential property is that a given tile's content never changes once rendered. Tile 14/2621/6339 always shows the same streets, the same parks, the same coastline. This makes tiles perfectly immutable and therefore perfectly cacheable. See Caching (rel-07) for why immutability is the property that allows indefinite TTLs.

2c. CDN-first tile serving

Because tiles are immutable, the tile origin server's only job is to generate and store them. The CDN handles 99.9%+ of traffic. A client requesting tile 14/2621/6339.png asks the nearest CDN edge node. On a hit, the response returns in under 10 ms from the edge — no origin involved. On a miss, the edge fetches from origin, caches with a one-year TTL, and returns. Origin load is tiny: only cold tiles (new zoom areas, newly rendered updates) ever reach it.

The critical design constraint: never put query parameters in tile URLs. A URL like /tiles/14/2621/6339.png?style=night&lang=en creates a different cache key for every style+language combination, multiplying cache misses by orders of magnitude. Instead, bake style into the path: /tiles/night/14/2621/6339.png. The path is deterministic and every CDN edge worldwide uses the same cache key.

2d. Separate infrastructure per workload

Routing and geocoding share nothing with tile serving. They run on separate clusters behind the same API gateway:

WorkloadBacking storeCaching strategyScale target
Tile servingObject storage (pre-rendered PNGs / MVT)CDN global edge, 1-year TTL>25M req/min at peak
GeocodingAddress DB (geohash-indexed)Redis, 24 h TTL for repeat queries~50K RPS
Reverse geocodingSame address DBRedis, LRU on popular coordinates~30K RPS
RoutingRoad graph (specialised graph DB)Redis for popular O-D pairs~10K RPS
Nearby searchPlaces DB (geohash-indexed)Redis per cell+type, 5 min TTL~20K RPS

2e. Per-API-key rate limiting

Every API call except tile serving must carry an API key. The key maps to a quota — for example, 10,000 requests/day on the free tier. A token-bucket counter in Redis tracks usage per key, resetting at midnight UTC. When the bucket empties, the gateway returns 429 Too Many Requests with a Retry-After header showing the seconds until quota reset. See Rate Limiting (rel-03) for the full token-bucket implementation pattern.

Tile requests are different: tiles are served from CDN without authentication (public URLs). Billing for tile usage happens by counting API-key-authenticated "session" or "map load" initialisations, not per individual tile.

3 · The API model

Geocoding — address to coordinates

# Forward geocode: address string → lat/lng
GET /v1/geocode?q=1600+Amphitheatre+Parkway%2C+Mountain+View%2C+CA&key=YOUR_API_KEY

# Response 200
{
  "status": "OK",
  "results": [
    {
      "place_id": "ChIJ2eUgeAK6j4ARbn5u_wAGqWA",
      "formatted_address": "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA",
      "geometry": {
        "location": { "lat": 37.4224, "lng": -122.0840 },
        "location_type": "ROOFTOP",
        "viewport": {
          "northeast": { "lat": 37.4237, "lng": -122.0827 },
          "southwest": { "lat": 37.4211, "lng": -122.0853 }
        }
      },
      "types": ["street_address"]
    }
  ]
}

Reverse geocoding — coordinates to address

# Reverse geocode: lat/lng → nearest address
GET /v1/geocode/reverse?lat=37.4224&lng=-122.0840&key=YOUR_API_KEY

# Response 200
{
  "status": "OK",
  "results": [
    {
      "place_id": "ChIJ2eUgeAK6j4ARbn5u_wAGqWA",
      "formatted_address": "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA",
      "geometry": {
        "location": { "lat": 37.4224, "lng": -122.0840 }
      },
      "address_components": [
        { "long_name": "1600",            "types": ["street_number"] },
        { "long_name": "Amphitheatre Pkwy", "types": ["route"] },
        { "long_name": "Mountain View",     "types": ["locality"] },
        { "long_name": "CA",               "types": ["administrative_area_level_1"] }
      ]
    }
  ]
}

Directions — routing between two points

# Driving directions: Mountain View → San Francisco
GET /v1/directions?from=37.4224,-122.0840&to=37.7749,-122.4194&mode=driving&key=YOUR_API_KEY

# Response 200
{
  "status": "OK",
  "routes": [
    {
      "summary": "US-101 N",
      "duration_seconds": 2460,
      "distance_meters": 55200,
      "polyline": "encoded_polyline_string_here",
      "legs": [
        {
          "steps": [
            {
              "instruction": "Head north on Amphitheatre Pkwy toward Charleston Rd",
              "distance_meters": 340,
              "duration_seconds": 42,
              "start_location": { "lat": 37.4224, "lng": -122.0840 },
              "maneuver": "straight"
            },
            {
              "instruction": "Merge onto US-101 N",
              "distance_meters": 47800,
              "duration_seconds": 2100,
              "maneuver": "merge"
            }
          ]
        }
      ]
    }
  ]
}

Nearby search — places within a radius

# Find restaurants within 500 m of a point
GET /v1/places/nearby?lat=37.4224&lng=-122.0840&radius=500&type=restaurant&key=YOUR_API_KEY

# Response 200
{
  "status": "OK",
  "next_page_token": "CqQCmgEAAA...",
  "results": [
    {
      "place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4",
      "name": "The Creamery",
      "vicinity": "1600 Shoreline Blvd, Mountain View",
      "geometry": {
        "location": { "lat": 37.4219, "lng": -122.0831 }
      },
      "rating": 4.3,
      "distance_meters": 118,
      "opening_hours": { "open_now": true },
      "types": ["restaurant", "food", "establishment"]
    }
  ]
}

Tile serving — rasterised map tiles

# Tile request — no API key; served from CDN edge
# z=14, x=2621, y=6339 → downtown Mountain View at street level
GET /v1/tiles/14/2621/6339.png

# Response 200 — binary PNG from CDN edge (<10 ms on cache hit)
# Headers:
Cache-Control: public, max-age=31536000, immutable
Content-Type: image/png
X-Cache: HIT
X-CDN-Pop: sjc-edge-07

# On a CDN miss, origin generates and stores the tile, then returns it.
# The origin adds the same Cache-Control header so the edge caches it
# for every subsequent request.
✅ Make tile URLs deterministic

The tile URL /v1/tiles/14/2621/6339.png uniquely identifies the tile with no query parameters. Every CDN edge in the world caches it under the same key. The moment you add a query parameter — even ?v=2 or ?lang=en — you fragment the cache space and obliterate your hit ratio. If you need style variants, bake them into the path: /v1/tiles/night/14/2621/6339.png and /v1/tiles/day/14/2621/6339.png are two independent, fully-cacheable paths.

API Gateway key auth + quota CDN Edge GET /tiles/z/x/y Tile Origin on cache miss only Load Balancer GET /geocode Geocoder + Redis cache Load Balancer GET /directions Routing Engine road graph + cache
Three request types — tile, geocode, directions — enter via the same API Gateway but fan out to completely separate infrastructure. Tiles skip rate-limiting per-tile and go directly to CDN edge.
🎯 Interview angle: handling billions of tile requests

A common interview question is: "Google Maps serves billions of tile requests per day. How?" The answer has two parts. First, tiles are immutable at a given z/x/y — zoom level 14, column 2621, row 6339 never changes (barring a map data update, which is infrequent). Second, because they're immutable, you can set Cache-Control: immutable, max-age=31536000 on every tile. CDN edges worldwide cache them permanently. The origin only sees requests for tiles that haven't been fetched recently from that edge — roughly 0.1% of total traffic. At Google's scale of ~25 million tile requests per minute, 99.9% cache hit ratio means origin handles only ~25,000 req/min — a very manageable number. Mention quadtree z/x/y coordinates and pre-rendering popular zoom levels (8–16) offline to warm the cache.

4 · Evaluation & latency budget

Tile cache hit ratio

Think of tile popularity as a long tail: zoom levels 10–16 covering major cities account for roughly 80% of all tile requests, and those tiles are requested millions of times each. CDN cache hit ratios above 99.9% are achievable because the same 256×256 pixel square is served identically to every client in every country. Zoom levels 18–20 (building detail, very narrow streets) are requested far less often and may have lower hit ratios, but they're a small fraction of traffic.

Zoom rangeApprox tile countCache hit ratioTypical TTL
0–7 (world/country)~21,000~100%7 days
8–13 (city/district)~6M99.9%+24 hours
14–16 (street level)~420M99%+24 hours
17–20 (building detail)>7B60–80%1 hour

Geocoding cache hit ratio

Geocoding is not as cache-friendly as tiles, but it is still substantially cacheable. The address "1600 Amphitheatre Parkway, Mountain View, CA" is queried millions of times by users searching for Google's campus. Common addresses, business names, and landmarks benefit from a Redis LRU cache with a 24-hour TTL. Expect 60–75% cache hit ratios for a geocoder serving a major metropolitan area.

Routing latency breakdown

Routing is the most computationally expensive workload. A naive on-demand Dijkstra or A* search on a full country road graph can take 500 ms – 2 seconds. Production routing engines use two optimisations:

Request typep50 latencyp95 latencyp99 latency
Tile (CDN hit)<5 ms<10 ms<20 ms
Tile (CDN miss, origin)30–80 ms120 ms200 ms
Geocoding (cache hit)<5 ms<10 ms<20 ms
Geocoding (DB lookup)30–60 ms150 ms300 ms
Routing (cache hit)<5 ms<10 ms<20 ms
Routing (on-demand CH)20–50 ms200 ms500 ms
Nearby search (geohash)10–30 ms80 ms200 ms

Back-of-envelope: tile origin load

Google Maps reportedly handles approximately 25 million tile requests per minute at peak. With a 99.9% CDN cache hit ratio:

417 RPS on origin is trivially served by a small cluster of tile servers — far simpler than the reverse-proxy and caching layer that precedes it. This is the core insight: the hard part of Maps at scale is not computation, it is cache design.

Under the hood: the core mechanism

Geospatial indexing is the mechanism that makes nearby-search fast. Three approaches are in widespread use — geohash, quadtree, and Google S2 cells — and they all solve the same problem: turning a two-dimensional lat/lng into a one-dimensional key so that a standard database index can answer "what is near X?" with a prefix or range scan rather than computing distances to every row.

How geohash encodes a coordinate

Geohash encodes latitude and longitude by interleaving their binary representations and then base-32-encoding the result. The algorithm, step by step for the point (37.4224, −122.0840):

Step 1 — Encode latitude into 15 bits by repeated bisection of [-90, 90]
Range      [  -90,   90]  midpoint= 0    →  37.4224 ≥ 0   → bit 1
Range      [    0,   90]  midpoint=45    →  37.4224 < 45  → bit 0
Range      [    0,   45]  midpoint=22.5  →  37.4224 ≥ 22.5→ bit 1
Range      [ 22.5,   45]  midpoint=33.75 →  37.4224 ≥ 33.75→ bit 1
Range      [33.75,  45]   midpoint=39.375→  37.4224 < 39.375→ bit 0
...                        (continue for 15 total bits)
lat bits = 1 0 1 1 0 ...

Step 2 — Encode longitude into 15 bits by repeated bisection of [-180, 180]
Range      [ -180,  180]  midpoint= 0    → -122.084 < 0   → bit 0
Range      [ -180,    0]  midpoint=-90   → -122.084 < -90 → bit 0
Range      [ -180,  -90]  midpoint=-135  → -122.084 ≥ -135→ bit 1
...
lng bits = 0 0 1 ...

Step 3 — Interleave: lng bit, lat bit, lng bit, lat bit, ...
Interleaved: 0 1 0 0 0 1 1 1 0 ...  (30 bits total for 5-char geohash)

Step 4 — Group into 5-bit chunks and map each to base-32 alphabet
base-32 alphabet: 0123456789bcdefghjkmnpqrstuvwxyz
chunk 0 = 01001 = 9
chunk 1 = 00111 = q
chunk 2 = 10000 = 8
chunk 3 = 11010 = y
chunk 4 = 10100 = y
Geohash = "9q8yy"  ← the ~4.9 km × 4.9 km cell containing Mountain View, CA

The precision table maps string length to approximate cell size:

Geohash lengthApprox cell widthApprox cell heightUse case
4 chars~39 km~20 kmCity-level sharding
5 chars~4.9 km~4.9 kmNeighborhood / nearby search (radius ≤ 5 km)
6 chars~1.2 km~0.6 kmStreet-level / radius ≤ 1 km
7 chars~153 m~153 mBuilding-level precision

Why shared prefix = geographic proximity

Because each character of the geohash encodes one more bisection, two strings that share a length-5 prefix fall within the same 4.9 km × 4.9 km parent cell. Two strings that share only a length-3 prefix ("9q8") fall within the same ~156 km × 156 km grandparent cell. The prefix is a spatial containment relationship encoded as a string, which is exactly what a B-tree index can scan efficiently.

prefix "9q8" ≈ 156 km × 156 km prefix "9q8y" ≈ 39 km × 20 km 9q8y7 9q8yy query cell 9q8yz 9q8ye 9q8yv 9q8yw Prefix scan logic 1. Encode point → "9q8yy" 2. Compute 8 neighbours: "9q8y7", "9q8yz", "9q8ye"... 3. SQL: geohash5 IN (9 values) — one index scan, no math 4. Haversine post-filter trims corner-of-cell false positives
Prefix hierarchy: "9q8" contains all of "9q8y", which contains all "9q8yy" cells. A nearby search at precision 5 queries the target cell plus 8 neighbours — nine string comparisons against an indexed column rather than a trigonometric full-table scan.

The boundary problem and the 9-cell fix

A query point near the edge of its geohash cell may be closer to records in adjacent cells than to records in its own cell. If you query only the centre cell, you silently miss nearby records that happen to fall in a neighbouring cell. The standard fix is to always query the centre cell plus all 8 surrounding cells (Moore neighbourhood). This over-selects: some returned records will be outside the actual radius. A Haversine post-filter culls those:

-- The Haversine formula gives true great-circle distance between two lat/lng points
-- Used as a post-filter AFTER the geohash index scan retrieves the candidate set
haversine(lat1, lng1, lat2, lng2) =
  2 × R × arcsin(
    sqrt(
      sin²((lat2−lat1)/2) + cos(lat1) × cos(lat2) × sin²((lng2−lng1)/2)
    )
  )
  where R = 6,371,000 m (Earth's mean radius)

Worked nearby-search trace

Finding all coffee shops within 800 m of (37.4224, −122.0840):

T+0ms Client sends: GET /v1/places/nearby?lat=37.4224&lng=-122.0840&radius=800&type=cafe T+1ms API server computes geohash at precision 6 (≈1.2 km cell fits 800 m radius): encode(37.4224, -122.0840, precision=6) → "9q8yyh" neighbours("9q8yyh") → ["9q8yyj","9q8yyn","9q8yym","9q8yyk","9q8yy5", "9q8yy4","9q8yy6","9q8yy7"] (8 cells) cells = ["9q8yyh"] + 8 neighbours → 9 cells total T+2ms Check Redis cache: key "nearby:9q8yyh:cafe:r800" → MISS T+3ms Execute DB query: SELECT place_id, name, lat, lng, haversine(lat,lng,37.4224,-122.0840) AS dist FROM places WHERE geohash6 IN ('9q8yyh','9q8yyj','9q8yyn','9q8yym','9q8yyk', '9q8yy5','9q8yy4','9q8yy6','9q8yy7') AND type = 'cafe' HAVING dist <= 800 ORDER BY dist ASC LIMIT 20; T+12ms DB returns 7 rows (index scan across 9 geohash6 cells, ~11,400 candidates scanned) T+13ms Cache result in Redis: key "nearby:9q8yyh:cafe:r800" TTL=300s T+14ms API returns 200 OK with 7 results ordered by distance ASC

Operating & debugging it

The maps system has three independent serving paths — tiles, geocoding, routing — that fail in distinct ways. Here are the signals and fixes for each.

Key metrics to monitor

MetricWhere to observeAlert threshold (example)
CDN tile hit ratioCDN analytics; X-Cache HIT/MISS header sampling<99% → check tile URL determinism; look for query params contaminating cache keys
Geocoder cache hit ratioRedis INFO stats: keyspace_hits / (keyspace_hits + keyspace_misses)<50% → cache TTL may be too short or working set too large for Redis memory
Nearby-search query latencyApplication timing; DB slow-query logp95 >150 ms → geohash index missing or precision mismatch expanding candidate set too large
Routing engine latencyApplication metrics: time per /directions requestp95 >500 ms → contraction hierarchy not built; road graph data too stale; cache miss rate high
API key quota exhaustion rateRedis counter: tokens_remaining per key; 429 rate in gateway logs>5% of keys exhausting quota daily → review free-tier limits; consider raising them to reduce churn

Diagnosing a slow nearby-search query

# EXPLAIN ANALYZE the geohash prefix query to find missing index $ psql -c "EXPLAIN ANALYZE SELECT place_id FROM places WHERE geohash6 IN ('9q8yyh','9q8yyj') AND type='cafe';" Bitmap Index Scan on places_geohash6_idx (cost=0.00..42.3) (actual time=1.2..1.2 rows=311) Index Cond: (geohash6 IN ('9q8yyh','9q8yyj')) -- If you see "Seq Scan" instead of "Bitmap Index Scan", the index is missing or not being used $ psql -c "CREATE INDEX CONCURRENTLY ON places(geohash6) WHERE geohash6 IS NOT NULL;" -- CONCURRENTLY = no table lock; safe to run on production # Verify tile URL is deterministic (no query params) $ curl -sI "https://cdn.example.com/v1/tiles/14/2621/6339.png" | grep -i 'x-cache\|cache-control' x-cache: HIT cache-control: public, max-age=31536000, immutable # If cache-control is missing or short, tile origin is not setting the header correctly # Check Redis cache for a geocode result $ redis-cli GET "geocode:1600+Amphitheatre+Parkway+Mountain+View+CA" "{\"lat\":37.4224,\"lng\":-122.0840,...}" # MISS (nil) → this query has not been cached; DB lookup on every request until first hit # Inspect quota counter for an API key $ redis-cli GET "quota:ak_liveXXXXXXXX:2026-06-20" 9847 # Value = requests used today. Key resets (expires) at midnight UTC.
SymptomLikely causeFix
Nearby search returns 0 results for a densely populated areaGeohash precision too fine → query covers too small an area; or boundary issue with only 1 cell queriedVerify 9-cell expansion is implemented; cross-check precision matches requested radius (use precision 5 for radius ≥ 2 km)
Nearby search returns many results outside the requested radiusHaversine post-filter missing; only geohash filter appliedAdd HAVING haversine(...) <= radius to the query; geohash is a candidate-generation step, not a precise distance filter
Tile CDN hit ratio drops from 99.9% to 85%A query parameter was added to tile URLs (e.g. API key, session ID, version); cache keys fragmentedAudit tile URL generation code; strip all query params from tile URLs; move API key auth to session-init level
Geocoding latency spikes to 500 msRedis cache eviction under memory pressure; all queries hitting DBIncrease Redis memory limit or lower the geocode result TTL to reduce working set size
Routing returns stale routes (closed road still appears)Road graph not refreshed with recent traffic/closure dataRebuild contraction hierarchy daily from fresh OSM/HERE data; invalidate route cache for affected geographic cells
Client gets 429 at 10 AM with quota reset at midnight UTCQuota bucket reset time is UTC midnight; client is in UTC+10 and reaches limit early local dayDocument the reset schedule clearly; consider per-client-timezone reset windows or rolling 24-hour windows instead
⚠️ Gotcha: geohash cells are not squares on a real map

Geohash cells appear square when plotted on a Mercator projection but are actually rectangles of varying aspect ratio depending on latitude. At 45° latitude, a precision-5 cell is roughly 4.9 km wide and 4.9 km tall. At 70° latitude (Scandinavia, Alaska), the same precision-5 cell is about 2.4 km wide and 4.9 km tall — roughly half the width. This means a "500 m radius" query at high latitudes may miss records that are within 500 m as the crow flies but fall outside the geohash cell boundary. The Haversine post-filter corrects for this, but if your query uses a fixed number of neighbour cells, you may need more cells at high latitudes to fully cover the radius. Google S2 uses spherical geometry and avoids this distortion entirely; it is a better choice for applications operating near the poles or requiring precise radius semantics globally.

🧠 Quick check

What property of map tiles makes them exceptionally cache-friendly?

Immutability is the key. Small file size and HTTP/2 are beneficial but not why tiles cache so well. The real reason is that tile 14/2621/6339 always shows the same streets — you can set Cache-Control: immutable and the CDN never has to revalidate it. This is what enables 99.9%+ hit ratios and year-long TTLs.

Geohash represents a location as a string like "9q8yy". "9q8yb" and "9q8yc" are nearby. Why?

Shared prefix = shared parent cell = geographic proximity. "9q8yb" and "9q8yc" both fall within the "9q8y" parent cell, so they are at most a few kilometres apart. This prefix-matching property is what allows a geohash proximity query to use a simple string index scan rather than trigonometric distance calculations.

A Maps API key has a quota of 10,000 requests/day. A client hits that limit at 2pm. What should the API return?

429 is the correct HTTP status for quota exhaustion. The Retry-After header tells the client exactly when to try again — typically midnight UTC when the daily quota resets. 503 implies a server-side failure (wrong). 402 is tempting for billing-related limits but 429 is the standard for rate limits. 200 with an error in the body is an anti-pattern — it breaks clients that check status codes.

✍️ Exercise: find all gas stations within 2 km

Design the complete request/response cycle for "find all gas stations within 2 km of the user's current GPS location" — from the client API call through geohash lookup to the JSON response. Your design should address:

  1. The exact API call the client makes.
  2. How the server computes the geohash for the given lat/lng and what precision (number of characters) it chooses for a 2 km radius search.
  3. What happens if the search radius crosses a geohash cell boundary.
  4. What the SQL/index query looks like against the places database.
  5. The response shape, including how results are ordered and how to handle the case where there are no gas stations within 2 km.

Model answer:

# Step 1 — Client API call
GET /v1/places/nearby?lat=37.4224&lng=-122.0840&radius=2000&type=gas_station&key=YOUR_API_KEY
# Step 2 — Geohash precision for 2 km radius
# Geohash precision table (approximate cell dimensions):
#   5 chars → ~4.9 km × 4.9 km  ← too coarse for 2 km radius
#   6 chars → ~1.2 km × 0.6 km  ← good fit; 2 km search uses 5-char prefix
# Use 5-char geohash of query point → expand to 9 cells (centre + 8 neighbours)
# This ensures the 2 km radius is fully covered even at cell edges

query_hash = geohash.encode(37.4224, -122.0840, precision=5)  # "9q8yy"
neighbours = geohash.neighbours(query_hash)               # 8 surrounding cells
cells = [query_hash] + neighbours                        # 9 cells total
# Step 3 — What happens at a cell boundary
# A gas station at lat/lng just inside a neighbouring cell IS included
# because we always query all 9 cells. The post-filter by Haversine distance
# removes any records that fall in a neighbouring cell but are actually
# more than 2000 m away (i.e. the corner of the cell is within 2 km
# but not all of it is). Geohash gives the candidate set; Haversine filters it.
# Step 4 — Database query
# places table has columns: place_id, name, type, lat, lng, geohash5 (indexed)
SELECT place_id, name, lat, lng,
       haversine(lat, lng, 37.4224, -122.0840) AS distance_meters
FROM   places
WHERE  geohash5 IN ('9q8yy', '9q8yz', '9q8yw', ...-- 9 cells)
  AND  type = 'gas_station'
HAVING distance_meters <= 2000
ORDER BY distance_meters ASC
LIMIT 20;
# Step 5 — Response shape
{
  "status": "OK",           # or "ZERO_RESULTS" if no gas stations found
  "results": [
    {
      "place_id":         "ChIJabc...",
      "name":            "Shell - Amphitheatre Pkwy",
      "geometry": { "location": { "lat": 37.4218, "lng": -122.0835 } },
      "distance_meters":  87,
      "opening_hours":   { "open_now": true },
      "types":           ["gas_station", "establishment"]
    }
  ]
}

# Zero results case:
{ "status": "ZERO_RESULTS", "results": [] }
# HTTP status is still 200 — ZERO_RESULTS is a valid, successful response.

Rubric:

Key takeaways

Sources & further reading