Skip to the solution
HardSystem Design16 min read

How would you design a 'nearby drivers' / proximity search?

822 views

The problem

Question presented to candidate: "A rider opens our app and we want to show the drivers within a kilometre or so, and then dispatch the closest available one. Millions of drivers send their GPS position every few seconds. How do you store those positions and answer 'who is near this point' fast enough?"

What a strong answer should cover:

  • Why a full scan (distance to every driver) does not scale, and the idea of a spatial index: bucket the map into cells so a query looks only at nearby cells
  • Geohash (a prefix = a rectangle; precision picks the cell size), or H3 hexagons / a quadtree, with the trade-offs between them
  • The query: rider cell plus its 8 neighbours, then an exact distance filter; the cell must be at least as large as the search radius or the 3x3 block misses drivers
  • The write path dominates: about 1.25 million location updates per second for 5 million drivers every 4 seconds; keep positions in memory (Redis GEO or an in-process grid), with a TTL so stale drivers disappear
  • Sharding by geography (city or cell prefix), hot cells (airports, stadiums), and separating "nearby" from "dispatch/matching"
  • Capacity numbers and what breaks first at 10x

Clarifying questions expected:

  • How many active drivers, how often do they report, and how many ride requests per second at peak?
  • Search radius, and do we need the K nearest or everyone within R? Straight-line or road distance?
  • How fresh must positions be, and is this only for display or also for dispatch?

Code / implementation expected: Optional โ€” the design with numbers; the example builds a geohash index over 10,000 drivers and compares query strategies.

  • geo
  • proximity

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

16 min read ยท 17 sections

Read the code

Geohash proximity search on 10,000 drivers: full scan vs own cell vs cell plus 8 neighbours, the radius trap, and cell churn
// Proximity search: geohash grid index vs full scan, on 10,000 simulated drivers.
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
let seed = 42;
function rand() { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }

function encode(lat, lon, precision) {
  let latLo = -90, latHi = 90, lonLo = -180, lonHi = 180;
  let hash = "", bits = 0, ch = 0, even = true;
  while (hash.length < precision) {
    if (even) { const mid = (lonLo + lonHi) / 2; if (lon >= mid) { ch = ch * 2 + 1; lonLo = mid; } else { ch = ch * 2; lonHi = mid; } }
    else { const mid = (latLo + latHi) / 2; if (lat >= mid) { ch = ch * 2 + 1; latLo = mid; } else { ch = ch * 2; latHi = mid; } }
    even = !even;
    if (++bits === 5) { hash += BASE32[ch]; bits = 0; ch = 0; }
  }
  return hash;
}
function cellSize(precision) { // degrees of latitude and longitude covered by one cell
  const lonBits = Math.ceil(precision * 5 / 2), latBits = Math.floor(precision * 5 / 2);
  return [180 / 2 ** latBits, 360 / 2 ** lonBits];
}
function haversineKm(lat1, lon1, lat2, lon2) {
  const r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(a));
}
function neighbours(lat, lon, p) { // the cell of (lat, lon) plus the 8 around it
  const [h, w] = cellSize(p), out = [];
  for (const dy of [-1, 0, 1]) for (const dx of [-1, 0, 1]) {
    const c = encode(lat + dy * h, lon + dx * w, p);
    if (!out.includes(c)) out.push(c);
  }
  return out;
}

console.log("geohash(57.64911, 10.40744, 11) = " + encode(57.64911, 10.40744, 11));
console.log("precision  cell height km  cell width km (at lat 37.76)");
for (const p of [4, 5, 6, 7]) {
  const [h, w] = cellSize(p);
  console.log("  " + p + "          " + haversineKm(37.76, -122.44, 37.76 + h, -122.44).toFixed(3) +
    "          " + haversineKm(37.76, -122.44, 37.76, -122.44 + w).toFixed(3));
}

const N = 10000, P = 6, drivers = [], index = new Map();
for (let i = 0; i < N; i++) drivers.push([37.70 + 0.12 * rand(), -122.52 + 0.16 * rand()]);
function add(id) { const c = encode(drivers[id][0], drivers[id][1], P); if (!index.has(c)) index.set(c, []); index.get(c).push(id); return c; }
const cellOf = drivers.map((_, i) => add(i));
console.log("drivers " + N + ", non-empty precision-6 cells " + index.size);

const rider = [37.7600, -122.4400];
console.log("rider cell " + encode(rider[0], rider[1], P));
function search(cells, radiusKm) {
  let checked = 0; const found = [];
  for (const c of cells) for (const id of index.get(c) || []) {
    checked++;
    const d = haversineKm(rider[0], rider[1], drivers[id][0], drivers[id][1]);
    if (d <= radiusKm) found.push([d, id]);
  }
  found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  return [checked, found];
}
for (const radius of [0.5, 1.5]) {
  const all = [];
  for (let id = 0; id < N; id++) all.push(id);
  let checked = 0; const truth = [];
  for (const id of all) { checked++; const d = haversineKm(rider[0], rider[1], drivers[id][0], drivers[id][1]); if (d <= radius) truth.push([d, id]); }
  truth.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  const [c1, f1] = search([encode(rider[0], rider[1], P)], radius);
  const [c9, f9] = search(neighbours(rider[0], rider[1], P), radius);
  console.log("radius " + radius.toFixed(1) + " km:");
  console.log("  full scan      checked " + checked + "  found " + truth.length);
  console.log("  own cell only  checked " + c1 + "  found " + f1.length);
  console.log("  cell + 8 nbrs  checked " + c9 + "  found " + f9.length);
  if (radius === 0.5) console.log("  nearest 3: " + f9.slice(0, 3).map(([d, id]) => "driver " + id + " at " + Math.floor(d * 1000) + " m").join(", "));
}

let moved = 0;
for (let id = 0; id < N; id++) {
  drivers[id][0] += (rand() - 0.5) * 0.0018; // up to about 100 m north or south
  drivers[id][1] += (rand() - 0.5) * 0.0022; // up to about 100 m east or west
  const c = encode(drivers[id][0], drivers[id][1], P);
  if (c !== cellOf[id]) { moved++; const list = index.get(cellOf[id]); list.splice(list.indexOf(id), 1); if (!index.has(c)) index.set(c, []); index.get(c).push(id); cellOf[id] = c; }
}
console.log("one 4-second tick: " + N + " position writes, " + moved + " changed cell (" + (100 * moved / N).toFixed(1) + "%)");

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 70 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How would you design Twitter's timeline at scale?

Continue