Google Maps System Design Interview Question
Problem: Design a web-scale map service that renders tiled maps, searches places, and computes routes from A to B.
Overview
Google Maps is the canonical consumer geo product and, underneath the slick UI, a stack of four distinct services glued together: a tile server that paints the basemap, a place search service that turns typed strings into coordinates, a routing service that produces turn-by-turn directions, and a traffic and ETA service that keeps those routes honest. Each one has its own data model, its own hot path, and its own scaling story, which is why a single 'design Google Maps' interview can take an entire hour. The tile server is read-mostly and absurdly cacheable; routing is compute-heavy and benefits from pre-processed graph contractions; place search is a classic autocomplete problem with spatial bias. This SEO supplement expands the widget with Java sketches for a tile server endpoint, a bidirectional Dijkstra routing skeleton, and a trie-backed place autocomplete service so the core mechanics are concrete.
Summary
A read-heavy geospatial system dominated by three very different workloads: (1) map tile fetches — billions of tiny immutable PNG/vector requests that belong on a CDN, (2) place autocomplete — low-latency prefix search out of Elasticsearch, and (3) routing — CPU-heavy graph traversal over the road network, accelerated with Contraction Hierarchies so 1000 km queries finish in <100ms. The dominant design choice is pushing the tile workload all the way to the CDN edge (99%+ cache hit) so the origin sees only rare misses; the main tradeoff is that map updates take hours to propagate globally as tiles are re-rendered and purged. Sized for ~1B MAU and ~10M route requests/day.
Requirements
Functional
- Serve raster or vector map tiles addressed by (z, x, y) for zoom levels 0-22.
- Resolve a free-text place query to ranked candidate locations with autocomplete.
- Compute driving, walking, cycling, and transit routes between two coordinates.
- Return live ETA for an active route, incorporating current traffic.
- Let clients request a route alternative or avoid-tolls / avoid-highways variant.
- Expose a /places/{id} endpoint for the place details card.
Non-functional
- Tile cache hit ratio above 99 percent at the edge; cold tiles under 300 ms p99.
- Route computation p99 under 500 ms for intra-country trips.
- Autocomplete p99 under 80 ms per keystroke.
- Tile storage is immutable and versioned so rolling back a bad render is instant.
- 99.99 percent regional availability; degraded mode returns stale traffic rather than error.
- Horizontally scalable to 1B+ DAU with regional graph partitioning.
Capacity Assumptions
- 1B MAU, 200M DAU
- Average user loads ~100 map tiles per session (viewport + pan)
- 10M route requests/day, 50M place searches/day
- Tile pyramid: zoom 0 to 21, ~5T unique tiles at z=21 (mostly ocean/empty → not stored)
- Vector tiles (mvt) ≈ 20 KB each after compression; raster PNG ≈ 100 KB
Back-of-Envelope Estimates
- Tile QPS: 200M * 100 / 86400 ≈ 230K tile fetches/sec → >99% CDN hit → origin ≈ 2K QPS
- Route QPS: 10M / 86400 ≈ 115 QPS, peak ~500 QPS
- Place search QPS: 50M / 86400 ≈ 580 QPS, peak ~2K QPS
- Tile storage: ~200 TB of vector tiles (world coverage, z=0..17) + incremental updates
- Road graph: ~2B nodes / 3B edges worldwide; fits in ~80 GB RAM per routing server after CH preprocessing
High-level architecture
The map UI is really four APIs talking to one shell. The tile path is the cheapest: vector tiles are rendered offline from OSM plus proprietary data into protobuf PBF blobs, stored in immutable object storage keyed by (style, z, x, y), and pushed to a global CDN. A TileServer instance only serves as a fallback when the CDN misses, typically adding a signed URL and stamping a cache header. The place search path fronts an Elasticsearch or custom inverted index with a trie cache for prefix completions; queries are re-ranked by geographic prior (distance from the user's viewport centroid) and click popularity. The routing path is the heaviest. The world road graph is partitioned into cells (typically country or sub-country) with contraction hierarchies pre-computed offline, so at query time a bidirectional Dijkstra or A* runs on a sparse overlay graph in tens of milliseconds rather than the hour it would take on the raw graph. Live traffic is a separate stream: a fleet of GPS probes feeds speed tiles into a Traffic Service that writes edge-weight deltas into a Redis layer that the router consults on every query. Everything is regional, and cross-region routes stitch together via a gateway graph whose edges are country-border crossings. Observability tracks CDN hit ratio per zoom level, router edge-relaxations per query, and autocomplete index staleness, all surfaced on a Google-Maps-team Grafana board during major event launches.
Architecture Components (10)
- Client (Browser / Mobile SDK) (client) — Map canvas that computes the visible tile set and fetches z/x/y tiles, plus UI for search and routing.
- CDN Edge (Tile POP) (cdn) — Global edge network (CloudFront / Fastly / Google's own) caching immutable tile blobs keyed by z/x/y.
- Load Balancer (lb) — L7 HTTPS LB at each region; routes /tiles/*, /place/*, /route/* to the right backend.
- Maps API Gateway (api) — Thin stateless gateway handling auth, quota, and dispatch to Tile/Route/Geocode/Place services.
- Tile Service (api) — Returns vector or raster tiles for a given z/x/y; hits a fast in-region cache first, then blob storage on miss.
- Tile Cache (Redis) (cache) — Regional Redis cluster caching recently-served tile bytes.
- Tile Blob Store (blob) — Object storage (S3/GCS) holding all pre-rendered tiles worldwide, versioned.
- Geocoding Service (api) — Converts addresses ↔ lat/lng using an in-memory trie of normalised addresses plus fallback to interpolation on road segments.
- Place Search (Elasticsearch) (search) — Prefix-matching autocomplete with geographic boost and popularity scoring, backed by Elasticsearch.
- Routing Service (api) — Computes shortest paths on the road graph using Contraction Hierarchies preprocessing for sub-100ms continent-scale queries.
Operations Walked Through (3)
- render-viewport — User pans the map. Client computes the visible z/x/y set and fetches 9 tiles. 99%+ served from CDN in <50ms.
- route-a-to-b — User requests directions from SF to LA. Routing service uses Contraction Hierarchies on the preprocessed road graph to return a path in ~100ms.
- place-autocomplete — User types each keystroke; client debounces to 200ms and sends a prefix query. Elasticsearch returns top 5 places ranked by relevance + proximity.
Implementation
@RestController
public class TileController {
private final TileStore store;
@GetMapping("/tiles/{style}/{z}/{x}/{y}.pbf")
public ResponseEntity<byte[]> vectorTile(
@PathVariable String style,
@PathVariable int z,
@PathVariable int x,
@PathVariable int y) {
if (z < 0 || z > 22) return ResponseEntity.badRequest().build();
long max = 1L << z;
if (x < 0 || x >= max || y < 0 || y >= max) {
return ResponseEntity.badRequest().build();
}
byte[] tile = store.get(style, z, x, y);
if (tile == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok()
.header("Content-Type", "application/x-protobuf")
.header("Content-Encoding", "gzip")
.header("Cache-Control", "public, max-age=86400, immutable")
.header("ETag", store.version(style))
.body(tile);
}
}
public class BidirectionalDijkstra {
private final Graph graph;
public Route shortestPath(long source, long target) {
Map<Long, Double> distF = new HashMap<>();
Map<Long, Double> distB = new HashMap<>();
PriorityQueue<Entry> forward = new PriorityQueue<>();
PriorityQueue<Entry> backward = new PriorityQueue<>();
forward.add(new Entry(source, 0));
backward.add(new Entry(target, 0));
distF.put(source, 0.0);
distB.put(target, 0.0);
double best = Double.POSITIVE_INFINITY;
long meeting = -1;
while (!forward.isEmpty() && !backward.isEmpty()) {
if (forward.peek().cost + backward.peek().cost >= best) break;
Entry f = forward.poll();
for (Edge e : graph.outgoing(f.node)) {
double nd = f.cost + e.weight();
if (nd < distF.getOrDefault(e.to(), Double.POSITIVE_INFINITY)) {
distF.put(e.to(), nd);
forward.add(new Entry(e.to(), nd));
if (distB.containsKey(e.to()) && nd + distB.get(e.to()) < best) {
best = nd + distB.get(e.to());
meeting = e.to();
}
}
}
Entry b = backward.poll();
for (Edge e : graph.incoming(b.node)) {
double nd = b.cost + e.weight();
if (nd < distB.getOrDefault(e.from(), Double.POSITIVE_INFINITY)) {
distB.put(e.from(), nd);
backward.add(new Entry(e.from(), nd));
if (distF.containsKey(e.from()) && nd + distF.get(e.from()) < best) {
best = nd + distF.get(e.from());
meeting = e.from();
}
}
}
}
return graph.reconstruct(source, target, meeting, distF, distB);
}
record Entry(long node, double cost) implements Comparable<Entry> {
public int compareTo(Entry o) { return Double.compare(cost, o.cost); }
}
}
public class PlaceTrie {
static class Node {
Map<Character, Node> next = new HashMap<>();
List<Place> topK = new ArrayList<>(); // pre-scored top results
}
private final Node root = new Node();
public void index(String name, Place p) {
Node cur = root;
for (char c : name.toLowerCase().toCharArray()) {
cur = cur.next.computeIfAbsent(c, k -> new Node());
insertTopK(cur.topK, p, 10);
}
}
public List<Place> prefix(String q) {
Node cur = root;
for (char c : q.toLowerCase().toCharArray()) {
cur = cur.next.get(c);
if (cur == null) return List.of();
}
return cur.topK;
}
private void insertTopK(List<Place> list, Place p, int k) {
list.add(p);
list.sort(Comparator.comparingDouble(Place::score).reversed());
if (list.size() > k) list.remove(list.size() - 1);
}
}
@RestController
public class PlaceController {
private final PlaceTrie trie;
private final ViewportRanker ranker;
@GetMapping("/places/autocomplete")
public List<Place> autocomplete(
@RequestParam String q,
@RequestParam double lat,
@RequestParam double lon) {
List<Place> candidates = trie.prefix(q);
return ranker.rerank(candidates, lat, lon);
}
}
Key design decisions & trade-offs
- Tile format — Chosen: Vector tiles (PBF) over pre-rendered raster PNGs. Vector tiles are an order of magnitude smaller over the wire and let the client restyle (night mode, language swap) without re-fetching, at the cost of more CPU on the client GPU. Raster is simpler but blows up storage by a factor of ten per style.
- Routing algorithm — Chosen: Contraction hierarchies plus bidirectional Dijkstra, not raw A*. Raw Dijkstra on a continent-scale graph takes multiple seconds; A* with great-circle heuristic helps but still scans millions of nodes. Contraction hierarchies pre-compute shortcut edges so the online search touches tens of thousands of nodes in under 100 ms.
- Traffic delivery — Chosen: Edge-weight deltas in Redis, not graph rebuilds. Rebuilding the contraction hierarchy every few minutes would cost tens of CPU-hours. Overlaying a weight delta at query time costs a single hash lookup per relaxed edge and keeps traffic freshness under one minute.
- Autocomplete index — Chosen: Custom trie for prefix speed, Elasticsearch for fuzzy matching. A trie answers strict prefix queries in microseconds but cannot handle typos; Elasticsearch handles fuzz via edit distance but is slower. Splitting the two keeps the hot 80 percent of queries on the trie and the rest on ES.
- Tile versioning — Chosen: Immutable tiles keyed by render version, not mutable overwrite. Mutable tiles would require per-tile cache invalidation across a global CDN, which is almost impossible to do cleanly. Immutable versioned URLs let a rollback flip a single style pointer and make every CDN cache entry correct.
Interview follow-ups
- Walk through how you would add transit routing with schedules on top of the road graph.
- How do you keep the live-traffic speed tiles accurate when probe density drops overnight?
- Design the Street View image pipeline and its tiling relationship with the basemap.
- Sketch the offline-maps feature, including how users download a region and how routing runs on-device.
- How do you evaluate a new routing algorithm against the production one without A/B harming users?