Engineering Atlas › System Design Interviews

Design a mapping and navigation service

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: satellite imagery capture and Street View, transit timetables and multimodal routing, place reviews, and the map editing tools themselves (we consume their output).

Back-of-envelope

Round assumed numbers, not Google's figures.

API

GET  /tiles/v{map_version}/{z}/{x}/{y}.mvt
     -> protobuf vector tile (roads, labels, buildings), Cache-Control: immutable

POST /v1/routes
     { origin: {lat, lng}, destination: {lat, lng}, depart_at?, avoid: ["tolls"], alternatives: 2 }
  -> { routes: [ { eta_s, distance_m, polyline, traffic_spans: [ {from_idx, to_idx, level} ],
                   steps: [ {maneuver, road_name, distance_m} ] } ],
       route_token, map_version }

WS   /v1/navigate?route_token=...
  -> { kind: "position", lat, lng, heading, speed, ts }            (batched every few seconds)
  <- { kind: "eta", eta_s }  |  { kind: "reroute", route }  |  { kind: "incident", ... }

GET  /v1/traffic?tile=z/x/y&v={traffic_epoch}   -> per-segment congestion levels for one tile
POST /v1/probes  { session_pseudonym, points: [ {lat, lng, ts, speed, accuracy_m} ] }

map_version in the URL makes tiles cacheable forever. route_token lets a navigation session resume on another server. Traffic is a separate short-lived overlay, so a speed change never invalidates a base tile.

Data model

road_segments (the editable source of truth): segment_id, from_node, to_node, geometry polyline, length_m, road_class, speed_limit, oneway, turn restrictions, toll flag. Partitioned by an S2 cell around level 6 so editors and builds work region by region. Never on the query path.

routing graph (compiled, read-only): nodes renumbered so nearby nodes get nearby IDs, edges in compressed sparse row arrays, plus shortcuts and a partition structure. One file per map version, loaded whole by each routing worker.

traffic_live: key (segment_id, direction) → speed_kmh, confidence, sample_count, updated_at. In memory, sharded by S2 cell so one shard owns a metro area, rewritten every 30 to 60 seconds.

traffic_history: (segment_id, day_of_week, slot) → median and 85th percentile speed. Columnar files rebuilt weekly, used for future departures and as the fallback when live data is thin.

tiles: object storage keyed v{map_version}/{z}/{x}/{y}. Build output, written once.

probes: an append-only log partitioned by time and keyed by a rotating session pseudonym rather than a user ID. Retained for days, not years.

High-level design

flowchart LR
  C["Phone / browser"] --> CDN["CDN edge"]
  CDN --> TS[("Tile store by version")]
  C --> GW["API gateway"]
  GW --> RS["Route service"]
  RS --> RW["Routing workers (graph in RAM)"]
  TR["Traffic store (live speeds)"] --> RW
  C -->|"location batches"| PI["Probe ingest"]
  PI --> Q["Log (Kafka)"]
  Q --> MM["Map matcher"]
  MM --> AG["Speed aggregator"]
  AG --> TR
  MD[("Road segments")] --> GB["Graph and tile builders"]
  GB --> TS
  GB --> RW

Write paths. There are two, on very different clocks. Slow: map edits land in road_segments; a periodic build compiles a new routing graph, regenerates only tiles whose cells changed, and publishes a new map_version. Fast: navigating phones send batched positions to probe ingest, which appends them to a log; map matchers snap traces to road segments, and the aggregator turns traversals into per-segment speeds every 30 to 60 seconds and publishes a new traffic epoch.

Read paths. For the map, the client computes which tiles cover the viewport and fetches them; nearly every request is a CDN hit, and the client renders vectors on the GPU. For a route, a routing worker with the current graph and traffic metric snaps origin and destination to the nearest segments, runs the query, builds alternatives and returns geometry plus ETA. During navigation, the session server compares positions with the route, reroutes on deviation, and refreshes the ETA as traffic epochs change.

Deep dives

1. Fast routes on a continent-sized graph

Plain Dijkstra settles every node closer than the destination: for a 1,000 km trip, millions of nodes, each costing cache misses, so seconds per query. On the standard Western Europe benchmark (18 million vertices, 42.5 million arcs), a random query with Dijkstra scans about 9.3 million vertices and takes about 2.2 seconds (Bast et al., Table 1). A* with a straight-line heuristic helps little on roads, and ALT (A* with landmarks) still settles hundreds of thousands of nodes.

Contraction hierarchies (CH) changed the game. Preprocessing orders nodes by importance and contracts them one at a time, adding a shortcut edge whenever removing a node would break a shortest path between its neighbours. A query runs two small Dijkstra searches, forward from the origin and backward from the destination, each only moving to more important nodes, meeting near the top of the hierarchy. On the same benchmark CH scans about 280 vertices and answers in about 110 microseconds after 5 minutes of preprocessing.

The catch is traffic: CH preprocessing bakes in edge weights and takes minutes for a continent, and its performance degrades with realistic turn costs. So I would split the work into a metric-independent phase (partition or order nodes from topology alone, once per map version) and a customisation phase (recompute shortcut weights from new speeds every minute). Customizable Route Planning (CRP) does exactly this: its topology phase takes about an hour, but a new cost function applies in roughly 370 ms on 12 cores, and queries take about 1.65 ms, slower than CH but nearly independent of the metric (Bast et al.). OSRM ships both: osrm-contract for CH, and osrm-partition plus osrm-customize for its multi-level Dijkstra (MLD), which its docs recommend when data needs live updates such as traffic (OSRM wiki). Customizable contraction hierarchies are a third option on the same principle. Google has not published what it runs, so say what you would build and why. Alternatives come from the same search: keep a few meeting points whose paths differ enough and are not much longer than the best.

2. Live traffic from phones

A raw GPS trace has 5 to 20 metres of error and does not say which of two parallel roads the car is on. Map matching uses a hidden Markov model: candidate segments near each point are hidden states, emission probability falls with distance from the point, and transition probability favours pairs whose route distance matches the straight-line distance between points. Viterbi picks the likeliest sequence, giving "segment S traversed in 42 seconds at 14:03". This is the formulation of Newson and Krumm, who tested it on a 1 Hz drive around Seattle and measured how accuracy falls as sampling gets sparser and noisier (Microsoft Research, 2009).

The aggregator needs enough distinct devices on a segment within a window before publishing a speed, for accuracy and for privacy: one phone on a quiet street identifies a person and says little. Below the threshold, use the historical speed. Trim the start and end of each trip so homes are never revealed, and weight recent samples so a new jam shows within one or two windows.

The ETA is a prediction, not a sum of current speeds: a segment 40 minutes ahead will have changed by the time you reach it. Blend live speeds near the origin with historical patterns further out, and correct with a learned model. Google and DeepMind described graph neural networks over "supersegments" of adjacent roads for this: ETAs were already accurate for over 97% of trips, and the model cut the remaining inaccurate ones by up to about 50% in cities such as Taichung (DeepMind, 2020). History also goes stale: when lockdowns cut traffic by up to 50% in early 2020, Google changed its models to weight the last two to four weeks of patterns over older data (Google, 2020). Plan for that knob from the start.

3. Tiles: raster or vector, built or rendered on demand

Raster tiles (256 x 256 PNGs) render identically everywhere, but every style, language and screen density needs its own set, and rotation blurs labels. Vector tiles ship geometry in a protobuf and the client draws it: several times smaller, with smooth zoom, tilt, dark mode and local-language labels from the same bytes, and one zoom 14 tile overzooms to cover zooms 15 to 20. I choose vector tiles, keeping raster for static image APIs.

Pre-building every non-empty tile is about 5 TB per version, cheap in object storage, and makes every request a static read. On-demand rendering saves storage but puts CPU on the request path during a cache-cold surge. Build up to zoom 14 ahead of time, rebuild only cells touched by edits, and publish atomically by flipping the version clients receive.

Down the stack

Layer 1The contract

  • Tiles. A URL with a given map_version always returns the same bytes. Clients may cache it forever. A new version may change any tile, and clients learn the current version from a small, short-TTL manifest.
  • Routes. For a given map_version and traffic epoch, the returned path is optimal for the stated metric (time with traffic) within the allowed restrictions, or the response says no route exists. The ETA is a prediction with a stated confidence, not a guarantee.
  • Traffic. Speeds are at most about two minutes stale; segments without enough samples report historical values and are marked as such.
  • Navigation. A reroute is offered within a few seconds of deviation. If the network drops, the client keeps guiding on the last route with locally cached tiles.

Layer 2The mechanism

The routing graph is in compressed sparse row form: first_edge of length nodes + 1 plus parallel target and weight arrays, so a node's outgoing edges are one contiguous slice. A CH query keeps two binary heaps and two distance arrays reset lazily (a generation stamp per entry instead of clearing millions of slots). It alternates forward and backward steps, records the best d_forward[v] + d_backward[v], and stops when both heap minima exceed it. Unpacking replaces each shortcut with the two edges it stands for, recursively.

Vector tiles follow the Mapbox Vector Tile specification: a protobuf of named layers whose feature geometry is a stream of MoveTo, LineTo and ClosePath commands with zigzag, delta-encoded integer coordinates on a grid whose size each layer must declare as its extent, commonly 4,096 units (MVT spec 2.1). Tile addressing uses Web Mercator: at zoom z there are 2^z by 2^z tiles, x growing east from longitude -180 and y growing south from latitude about 85.0511, the cutoff that makes the projected world square (OpenStreetMap wiki).

Layer 3The machine

  • Memory, not CPU, bounds routing. A DRAM miss costs about 80 to 100 ns. Dijkstra settling about 9M nodes incurs tens of millions of misses: the benchmarked 2.2 seconds. A CH query touches a few thousand edges: around a hundred microseconds. Renumbering nodes along a space-filling curve keeps neighbours on the same 64-byte cache lines, often a 2x to 3x win.
  • Graph per worker. 4 GB of core arrays plus shortcuts fits a 64 GB host with room for two map versions during a swap. mmap a prebuilt file so a new worker starts in seconds instead of parsing.
  • Customisation cost. Recomputing shortcut weights is a bottom-up pass, parallel within a level; the published CRP figure for Western Europe is about 370 ms on 12 cores, so even with real-world turn tables and several metrics (car, truck, avoid tolls) a continent stays within seconds, which sets the floor on routing freshness.
  • Tile bytes. A 10 to 50 KB tile needs one to four round trips on a fresh TCP connection (an initial window of min(10*MSS, max(2*MSS, 14600)) bytes, about 14 KB (RFC 6928)). HTTP/2 or HTTP/3 on one warm CDN connection keeps 50 fetches from paying 50 handshakes.

Why it's built this way

  1. Shortest paths on raw road graphs are dominated by memory latency, so precompute a hierarchy that lets queries touch thousands of nodes instead of millions.
  2. Precomputation that bakes in weights cannot keep up with traffic, so separate the topology phase from a fast weight customisation phase.
  3. The whole graph fits in one machine's RAM, so replicate it to every worker instead of sharding it, and avoid cross-machine hops on the critical path.
  4. Map geometry changes slowly but is read billions of times, so build immutable tiles offline and let CDNs absorb the reads.
  5. Speeds change by the minute and only phones see them, so collect probes, match them to roads, and aggregate with privacy thresholds.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Plain Dijkstra or A*No preprocessing, any weights instantlySeconds per long querySmall graphs, city-scale delivery
Contraction hierarchiesSub-millisecond queries, simple query codeWeight change needs re-preprocessingStatic weights, offline routing on device
Partition-based customizable routing (CRP, MLD)Fast queries and seconds-long traffic updatesMore complex, somewhat slower queries than CHLive traffic at continental scale
Raster tilesSimple clients, identical outputBig, one set per style and language, blurry when rotatedStatic images, legacy clients
Vector tilesSmall, restyle and rotate on device, overzoomClient rendering cost, harder to debugInteractive apps on modern devices
Pre-built tilesStatic reads, predictable costStorage and rebuild timeMost zoom levels
On-demand renderingOnly pays for viewed tilesCPU on the request path, cold surgesRare custom styles, very high zooms

Leadership lens

Check yourself

  1. Why is plain contraction hierarchies not enough once live traffic is involved, and what is used instead?

    Show answer

    CH preprocessing bakes in edge weights and takes minutes for a continent, so it cannot absorb new speeds every minute. A customizable approach (CRP or MLD, or customizable CH) does a metric-independent phase from topology once per map version and recomputes shortcut weights from new speeds in seconds, at the cost of more complexity and somewhat slower queries than CH.

  2. The traffic pipeline stalls. How should the system detect and handle it?

    Show answer

    Publish the traffic epoch timestamp and alert on its age. When it is too old, routing workers switch to historical speeds, which also serve as the fallback for segments without enough samples.

  3. Why can the routing graph be replicated to every worker instead of being sharded?

    Show answer

    About 500M directed segments at 4-byte target and 4-byte weight per edge is a 4 GB core, plus about as much again for shortcuts, which fits in RAM on one machine. Replicating it keeps cross-machine hops off the critical path and lets workers scale as stateless replicas.

  4. How does the aggregator protect privacy and resist spoofed traffic such as a wagon of 99 phones?

    Show answer

    It publishes a segment speed only when enough distinct devices traversed it in a window, falling back to historical speed otherwise, and trims trip starts and ends so homes are not revealed. Against spoofing it requires device diversity, plausible motion and agreement with neighbouring segments, and caps how far a small sample can move a busy road's speed.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →