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.
By the end you'll be able to
- Explain geohash prefix matching, why it enables efficient proximity queries, and how it drives sharding decisions.
- Describe the quadtree tile coordinate system and why tile URLs must be fully deterministic to achieve near-perfect CDN cache hit ratios.
- Sketch the full Maps API surface — geocoding, reverse geocoding, routing, nearby search, and tile serving — with realistic request and response shapes.
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.
| Requirement | What 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 routing | Shortest/fastest path between two points; respect travel mode (driving, walking, cycling) |
| Nearby search | Find places of a given type within a radius — restaurants, gas stations, hospitals |
| Map tile serving | Rasterised or vector tiles at any zoom level, x/y coordinate |
| Massive read scale | Billions of tile requests per day; geocoding and routing at tens of thousands of RPS |
| Low latency | Tile: <50 ms p95; geocoding: <200 ms p95; routing: <500 ms p95 |
| High availability | 99.99% uptime — maps are mission-critical for navigation and emergency services |
| Per-API-key quota enforcement | Free 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.
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:
- Proximity queries: to find all restaurants near a point, compute the point's geohash, then query for records whose geohash starts with the same 5–6 character prefix, plus the eight adjacent cells (neighbours). No trigonometry required — just string prefix matching.
- Sharding: assign each geohash prefix to a database shard. Shard "9q8" handles all of the Bay Area. Queries are routed to a single shard with no cross-shard joins for regional lookups.
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.
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:
| Workload | Backing store | Caching strategy | Scale target |
|---|---|---|---|
| Tile serving | Object storage (pre-rendered PNGs / MVT) | CDN global edge, 1-year TTL | >25M req/min at peak |
| Geocoding | Address DB (geohash-indexed) | Redis, 24 h TTL for repeat queries | ~50K RPS |
| Reverse geocoding | Same address DB | Redis, LRU on popular coordinates | ~30K RPS |
| Routing | Road graph (specialised graph DB) | Redis for popular O-D pairs | ~10K RPS |
| Nearby search | Places 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.
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.
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 range | Approx tile count | Cache hit ratio | Typical TTL |
|---|---|---|---|
| 0–7 (world/country) | ~21,000 | ~100% | 7 days |
| 8–13 (city/district) | ~6M | 99.9%+ | 24 hours |
| 14–16 (street level) | ~420M | 99%+ | 24 hours |
| 17–20 (building detail) | >7B | 60–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:
- Contraction hierarchies (CH): pre-process the road graph to create "shortcut edges" that skip unimportant intermediate nodes. A CH-accelerated A* delivers country-scale routing in 5–50 ms on modern hardware.
- Popular O-D pair caching: cache the full route result in Redis for origin-destination pairs that appear frequently (e.g., airport → city centre). Cache key:
geohash(origin, 4chars):geohash(dest, 4chars):mode. TTL: 5–15 minutes (traffic data changes).
| Request type | p50 latency | p95 latency | p99 latency |
|---|---|---|---|
| Tile (CDN hit) | <5 ms | <10 ms | <20 ms |
| Tile (CDN miss, origin) | 30–80 ms | 120 ms | 200 ms |
| Geocoding (cache hit) | <5 ms | <10 ms | <20 ms |
| Geocoding (DB lookup) | 30–60 ms | 150 ms | 300 ms |
| Routing (cache hit) | <5 ms | <10 ms | <20 ms |
| Routing (on-demand CH) | 20–50 ms | 200 ms | 500 ms |
| Nearby search (geohash) | 10–30 ms | 80 ms | 200 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:
- CDN edges absorb: 25,000,000 × 0.999 = 24,975,000 req/min
- Origin receives: 25,000,000 × 0.001 = 25,000 req/min (~417 RPS)
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 length | Approx cell width | Approx cell height | Use case |
|---|---|---|---|
| 4 chars | ~39 km | ~20 km | City-level sharding |
| 5 chars | ~4.9 km | ~4.9 km | Neighborhood / nearby search (radius ≤ 5 km) |
| 6 chars | ~1.2 km | ~0.6 km | Street-level / radius ≤ 1 km |
| 7 chars | ~153 m | ~153 m | Building-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.
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):
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
| Metric | Where to observe | Alert threshold (example) |
|---|---|---|
| CDN tile hit ratio | CDN analytics; X-Cache HIT/MISS header sampling | <99% → check tile URL determinism; look for query params contaminating cache keys |
| Geocoder cache hit ratio | Redis 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 latency | Application timing; DB slow-query log | p95 >150 ms → geohash index missing or precision mismatch expanding candidate set too large |
| Routing engine latency | Application metrics: time per /directions request | p95 >500 ms → contraction hierarchy not built; road graph data too stale; cache miss rate high |
| API key quota exhaustion rate | Redis 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Nearby search returns 0 results for a densely populated area | Geohash precision too fine → query covers too small an area; or boundary issue with only 1 cell queried | Verify 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 radius | Haversine post-filter missing; only geohash filter applied | Add 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 fragmented | Audit tile URL generation code; strip all query params from tile URLs; move API key auth to session-init level |
| Geocoding latency spikes to 500 ms | Redis cache eviction under memory pressure; all queries hitting DB | Increase 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 data | Rebuild 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 UTC | Quota bucket reset time is UTC midnight; client is in UTC+10 and reaches limit early local day | Document the reset schedule clearly; consider per-client-timezone reset windows or rolling 24-hour windows instead |
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:
- The exact API call the client makes.
- 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.
- What happens if the search radius crosses a geohash cell boundary.
- What the SQL/index query looks like against the places database.
- 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:
- ✓ Correct use of geohash precision 5 (or justified choice of 4/6 with reasoning).
- ✓ Queries 9 cells (centre + 8 neighbours) to handle boundary crossing — not just the centre cell.
- ✓ Applies Haversine post-filter to cull corner candidates that are in a neighbouring cell but exceed the radius.
- ✓ Orders by
distance_meters ASC— nearest first is the expected UX for nearby search. - ✓ Returns
ZERO_RESULTSwith HTTP 200, not an error code — no gas stations is a valid result, not a failure. - ✓ Includes a
LIMITto prevent enormous response payloads; usesnext_page_tokenpattern for pagination if many results.
Key takeaways
- Tiles are immutable. A tile at z/x/y never changes once rendered. Set
Cache-Control: immutable, max-age=31536000and let CDN edges worldwide absorb 99.9%+ of traffic — origin barely wakes up. - Deterministic tile URLs are non-negotiable. Any query parameter in a tile URL creates a new cache key. Bake style variants into the path so every CDN edge shares the same cache namespace.
- Geohash gives you proximity queries and sharding for free. Cells that share a long prefix are geographically adjacent. Query nine cells (centre + 8 neighbours) to handle boundary edge cases; then post-filter with Haversine to trim the result set.
- Never use a bounding-box SQL query without a spatial index.
WHERE lat BETWEEN x AND y AND lng BETWEEN a AND bdegrades to a full table scan. Use geohash prefix matching or PostGISST_DWithininstead. - Separate the three workloads. Tile serving, geocoding, and routing have completely different performance profiles and backing stores. Conflating them into one service makes each worse; separating them lets each be optimised independently.
- Per-API-key quota enforcement belongs at the gateway. Return 429 with
Retry-Afterwhen the daily bucket empties. Tile URLs deliberately carry no API key — billing for tiles happens at session-init level, not per-tile.
Sources & further reading
- Google Maps Platform — Official API documentation
- Wikipedia — Geohash: encoding, precision table, neighbour algorithms
- Uber H3 — Hierarchical hexagonal spatial indexing (alternative to geohash)
- OpenStreetMap — Slippy Map tile names: z/x/y coordinate system explained
- Mapbox Tilesets — Vector tile specification and serving guide