Skip to the written walkthrough
Inking the city plan
Building the scene
Compiling materials
Ready for input
Real-time ride-hailing dispatch

Two million cars,
moving, every four seconds.

A rider taps a button and expects a car within seconds. To answer, the system must know where every nearby driver is right now, pick one, and hold that decision — while every one of those drivers is rewriting its own position several times a minute. The hard part is not the map. It is that the data is never still, and the answer is only correct for about as long as it takes to render.

500Klocation writes / sec
870matches / sec at peak
< 2 srequest → driver offered, p99
2.5Mconcurrent sockets
scroll, or press ↓
01Scope

What the system has to do.

Five flows. Everything else in the product hangs off them, and everything hard about the design is already visible in the first three.

FlowShapeVolume
Driver location updateFire-and-forget, over a live socket500K/s
Rider requests a rideRequest/response, must not be lost870/s
Match & offerServer-initiated push to one driver~1.4K/s
Live trip trackingFan-out of one driver’s pings to one rider400K conns
Price quoteRead-mostly, cached per cell2K/s

Out of scope here, on purpose: payments, driver onboarding, fraud, routing itself (assume a road-graph ETA service exists and answers in ~30 ms), maps and map matching. They are real systems, but they are not what makes dispatch hard.

The shape of the problem Every one of these is a geospatial question against data that is being rewritten continuously. That combination — a 2-D range query over a table with a 500K/s write rate — is what forces almost every decision that follows.

The board on the right is a printed city plan with a hexagonal index laid over it, plus the dispatch bench off to the side. Drag things. Press I for a keyboard-reachable index of everything on it.

02Service levels

What “working” means, in numbers.

ObjectiveTargetWhat breaks past it
Request → driver offeredp99 < 2 sRider re-taps; duplicate requests
Dispatch compute alonep99 < 150 msThe 2 s budget is eaten by one hop
Location freshnessp99 < 6 sYou dispatch to where a car was
Trip state availability99.99%A trip in progress becomes unreachable
Dispatch availability99.95%~22 min/month of a city not matching
Pricing availability99.9%Degrade to 1.0× and keep matching

The 2-second budget is not generous once you spend it honestly:

rider → edge, mobile RTT180 ms
edge → regional dispatch25 ms
geo index lookup + candidate filter35 ms
road-graph ETA for the top 12 candidates40 ms
assignment + trip record write (quorum)30 ms
push to the driver’s socket190 ms
server-controlled total500 ms

Which leaves ~1.5 s of slack — and that slack is exactly what a batched matching window later spends, deliberately, to buy a better assignment.

Three properties that decide the architecture Soft state. A driver’s position is worthless in ten seconds. Losing the whole location index costs you one ping interval, not data. Write-heavy. Writes outnumber reads roughly 500:1. Every index you keep is paid for on the write path. Geographically partitioned. A rider in Lagos will never be matched to a driver in Lima, so the workload shards perfectly by geography — which is also its own trap.
03Back of the envelope

How much of everything.

Start with the only number that matters — how often a car tells you where it is. Move the sliders below; every figure in green is computed live.

drivers online at peak2.0M
ping interval4.0 s
→ location writes500,000 /s
wire bytes per ping id·lat·lon·ts·heading·accuracy, framed80 B
sustained ingest40 MB/s · 3.5 TB/day

If you durably wrote every ping. Three replicas, plus an index entry, plus a write-ahead log — call it 3.2× the raw bytes on disk:

raw / day3.5 TB
× 3.2 replication + WAL + index11.1 TB/day
per year4.0 PB

So you don’t. The current position lives in memory and is overwritten in place; the durable path gets a downsampled trace, and only for drivers who are actually on a trip (you need those for fare disputes and safety, nothing else):

drivers on a trip ~20% of online400,000
trace point every15 s
→ durable writes26,667 /s
durable volume184 GB/day · 67 TB/yr

The live index itself is small. That is the whole reason this design works:

per driver: id 8 + lat/lon 8 + cell 8 + state 4 + ts 836 B
× 4 for map overhead, buckets, back-pointers144 B
whole planet, in RAM288 MB

Connections and matches. 25M trips/day averages 289/s; peak hour runs about 3× the daily mean.

peak matches870 /s
offers issued ~1.6 offers per accepted match1,392 /s
concurrent sockets drivers + riders in-app2.5M
÷ 100K conns per gateway node25 nodes → run 60
The number to remember 288 MB. The entire live position of every driver on Earth fits in the RAM of a laptop. Every later decision — in-memory index, soft state, cheap failover, sharding for throughput rather than for capacity — falls out of that one fact.
04The obvious answer

Why a B-tree cannot do this.

The first design anyone writes is a table and a bounding box:

-- drivers(id, lat, lon, state, updated_at), INDEX (lat, lon)
SELECT id, lat, lon FROM drivers
WHERE lat BETWEEN 37.7610 AND 37.7790
  AND lon BETWEEN -122.4269 AND -122.4041
  AND state = 'idle'
ORDER BY st_distance(pt, :rider) LIMIT 12;

Run the scan on the board. The ochre band is what the index actually reads; the ring is what the rider actually wanted.

drivers in the band the index scans
drivers genuinely inside the radius
read amplification

Three separate failures, in order of severity.

  1. A B-tree is a one-dimensional total order. A composite index on (lat, lon) can only seek on the leading column. Because lat is a float, no two rows share a leading value, so lon never narrows the seek — it is a post-filter applied to every row the latitude range returns. You are running a 1-D range scan and throwing most of it away.
  2. The band is not local. On a single global table, a ±1 km latitude range selects drivers at that parallel everywhere on Earth. San Francisco’s query reads San Jose, Seoul’s reads Pyongyang. Read amplification stops being 2–3× and becomes 20–50×.
  3. It is the wrong table to index at all. Every one of the 500,000 pings per second changes an indexed column. That is not an in-place update; it is a delete plus an insert into a random B-tree leaf, plus a WAL record, plus later compaction or vacuum. Index maintenance, not the query, is what kills it.
And the query is still wrong A latitude/longitude box is not a circle, and one degree of longitude is 111 km at the equator and 78 km in Copenhagen. The box you scanned is the wrong shape, at a size that changes with where you are standing.

The fix is not a faster B-tree. It is to choose a one-dimensional order that preserves two-dimensional locality, so that “near in space” becomes “near in the index”. That is a space-filling curve, and it is what every real answer here is built on.

05Choosing an index

Geohash, quadtree, S2, H3.

SchemeCurve / shapeBoughtCost
GeohashZ-order, lat/lon rectanglesPlain strings; prefix = containment; works in any KV storeZ-order jumps: adjacent points can differ in the first character. Cell width shrinks toward the poles.
QuadtreeAdaptive subdivisionAbsorbs density skew — deep downtown, shallow in the desertA mutable tree. Rebalancing under 500K writes/s is a lock-contention problem, not a data-structure problem.
S2Hilbert curve on a cubeMuch better locality than Z-order; near-equal-area cells; excellent range coveringQuadrilaterals: 8 neighbours at two different centre distances. “Adjacent” is ambiguous.
H3Hexagons on an icosahedron6 neighbours, all at one centre distance. No corner adjacency. Distance in rings is meaningful.Hexagons do not nest exactly — a child is not fully inside its parent. And there are 12 pentagons per resolution.

The inset on the board makes the neighbour argument concrete. Switch between the two patches in the dock and watch the ring.

Why hexagons win for dispatch Dispatch does exactly one kind of query: expand outward from here until I have enough candidates. On a square grid, “one step out” means eight cells at two different distances — the four corner cells are 41% further away, so a ring is not a distance. On a hex grid every neighbour centre is the same distance away, so ring k really is “about k × cell-spacing away”, and the early-stop rule “stop expanding once I have twelve candidates” is sound.

The choice. H3 at resolution 9 for the live index, keyed by the 64-bit cell id, with resolution 8 and 7 parents stored alongside for cheap roll-ups (surge, supply heat, analytics). Real H3 figures, for calibration:

ResAvg areaEdgeCentre spacing
75.161 km²1,220 m2,113 m
80.737 km²461 m799 m
90.105 km²174 m301 m
100.0150 km²65.9 m114 m

Each H3 resolution divides cell area by seven. The board’s four levels divide by about 2.7 so that all four fit legibly on one plan — the argument is identical, the ratio is drawn smaller.

The honest caveat Because hexagons do not tile hierarchically, h3ToParent is approximate at the boundary: a res-9 cell can straddle two res-8 parents. For roll-ups that is noise. If you ever need exact containment — legal geofences, city boundaries, airport pickup zones — use polygons, not cells.
06Resolution & k-ring

Fine cells, or few cells.

A dispatch is a k-ring search: take the rider’s cell, take its neighbours, then theirs, until the rings cover the radius you are willing to send a car across. Ring k contains 3k² + 3k + 1 cells — 1, 7, 19, 37, 61, 91.

k = 01 cell
k = 17 cells
k = 219 cells
k = 337 cells
k = 461 cells

The radius is the product decision — how far you are willing to send a car — and k is only the bill for covering it. At 800 m that bill is one ring at res 7 and five at res 10, for exactly the same ground. Move both sliders and watch the two numbers trade places.

Holding the radius at 800 mCoarse — res 7Fine — res 10
Rings neededk = 1k = 5
Cells looked up per dispatch791
Cars sharing one bucket~80 — a hot key every one of them writes tounder 1
Ground actually searchedOvershoots the radius by a third; every extra car is filtered out againHugs the radius
Roll-ups for surge and heatAlready at the right grainAggregate 49 children to get there
Key spaceSmallMore keys, more per-bucket overhead

That is the whole trade, and it is not a trade between speed and accuracy — it is a trade between lookups and contention. Going one resolution finer multiplies the cells you touch by about three and divides the cars per bucket by about seven. So dispatch latency is U-shaped, and the minimum is not at either end:

fixed: RPC, queueing, serialisation12 ms
+ cells queried× 0.55 ms
+ candidates filtered× 0.09 ms
+ hot-bucket contention
dispatch compute
The production answer Index at res 9, and store the res-8 and res-7 parents alongside. Res 8 is cheaper to query, res 9 is more precise, so keep both and let the caller pick: a dense downtown request resolves at res 9 with k=3; a thin suburb rolls up to res 8 and takes fewer, bigger bites for the same radius. Then cap the radius, not the cell count — a car 1.5 km away is not a match, however cheap it was to find. When the radius is exhausted with nothing inside it, that is not an error to retry: it is the surge signal, and it is the most valuable event the system produces.

Drop the rider pin into an empty corner of the plan to see exactly that: the rings expand to cover the radius, nothing inside it is free, the request fails and the cell heats up.

07Location ingest

500,000 writes a second, and none of them matter for long.

The ingest path is deliberately split in two, and the split is the whole trick.

PathDestinationDurabilityLatency
HotIn-memory geo index, overwritten in placeNone. It is a cache of the present.< 1 ms
DurablePartitioned log → trip traces, analytics, MLReplicated, retained daysasync

Why the hot path has no durability at all. The value of a position decays to zero in one ping interval. If a geo-index shard dies, every driver it owned re-pings within four seconds and the replacement is fully populated. Rebuild time is pingInterval, not a restore from backup. Paying for durability on 500K writes/s to protect four seconds of a self-healing cache is the single most expensive mistake available here.

Connections, not requests. Drivers hold a long-lived socket — a WebSocket or a QUIC stream — because the alternative is 500,000 TLS handshakes a second, and because the server needs to push offers. Sockets are the cost centre:

concurrent driver sockets2.0M
bytes per socket kernel bufs + TLS state + app~40 KB
→ memory just to hold them open80 GB
÷ gateway nodesat 100K/node → 20 nodes

Gateways are stateless fan-in: terminate TLS, authenticate, decode, and forward to the geo-index shard that owns the driver’s cell. They keep no per-driver state beyond the socket, so a gateway can be drained and replaced without touching dispatch.

The tradeoff the slider makes visible Longer ping intervals cut ingest linearly — and make the index stale. The matcher chooses using the last reported position, so a stale index dispatches to where a car was, and the actual pickup ETA drifts above the estimate. Watch avg ETA separate from quoted ETA as you slow the pings down.

Backpressure. A ping is the most droppable message in the system: if a shard is saturated, drop the older queued ping for that driver and keep the newest. Coalescing by driver id turns a queue into a state and makes overload harmless.

08Assignment

Nearest-first is a local optimum.

Greedy dispatch answers each request the instant it arrives with the nearest idle car. It is correct, it is fast, and it is measurably worse than waiting.

The failure, minimally. Rider A appears; the only car nearby is 200 m away and greedy gives it to A. One second later Rider B appears 80 m from that same car; B now gets a car from 900 m out. Total pickup distance 1,100 m. Had you waited one second and solved both together: 80 + 400 = 480 m.

Batched window matching. Collect requests for a fixed window — 2 to 5 seconds — then solve a bipartite minimum-cost assignment over the whole batch. Cost is road ETA, not straight-line distance, plus terms for driver acceptance probability and for how long the rider has already waited.

Greedy nearest

avg pickup ETA time running empty

Batched window

avg pickup ETA time running empty

Press Run a 20-minute A/B in the dock. Both arms replay the same seeded demand against the same starting board, so the difference is the algorithm and nothing else.

The second figure is empty running: the share of a busy driver’s time spent driving to a pickup rather than carrying someone. It is the number that decides whether a shift pays, and shortening pickups is the only way to move it.

What it costs. Up to one window of added latency on every request — which is exactly the 1.5 s of slack the SLO budget left over. And a harder algorithm: Hungarian is O(n³), so at 400 requests × 900 candidate drivers you use an auction or min-cost-flow solver with a candidate cap, which lands in single-digit milliseconds.

Also in the cost functionWhy
Rider wait so farPrevents starvation: an unlucky request must eventually win
P(driver accepts)A declined offer costs a whole round trip; learned per driver × context
Trip destinationRepositioning value — sending a car toward a supply hole is worth real money
Driver’s time onlineFairness across the supply base, not just efficiency
The general shape Batching converts latency you were given into global quality you were not. Any system with a request stream and a shared scarce resource has this lever: hold a window, solve the whole window, and beat first-come-first-served by a margin that grows with load.
09Supply, demand, price

The board is a control loop.

Per cell, over a rolling window, count open requests and idle drivers. The ratio is the only input:

ratio= (open + 0.6×ring1) / (idle + 0.6×ring1)
multiplier= clamp(1 + β(ratio − r₀), 1.0, 3.4)
then damped over ~3 min and rate-limited±0.2× / min

The loop. Price rises → some riders defer (demand elasticity) and drivers move toward the ochre cells (supply elasticity) → the ratio falls → the price falls. Turn the surge toggle on and watch the tokens migrate: unmatched requests drop and average ETA follows, without a single extra driver existing.

Why the damping is not optional This is a feedback loop with delay — drivers take minutes to arrive, and they respond to a price that was true when they set off. Undamped, it oscillates: price spikes, everyone converges, price collapses, everyone leaves, price spikes. The fixes are conventional control theory: smooth over a window much longer than the transport delay, rate-limit the derivative, add hysteresis on the way down, and cap the multiplier.

Consistency of a price. A quote is generated once, stamped with a quote id, and honoured for its TTL. It must not be recomputed at accept time, or a re-tap after a network blip charges a different fare. So the quote is a small, durable, idempotent record — while the surge surface that produced it is soft state that can be thrown away and recomputed from the last few minutes of events.

Degrading. If the pricing service is unavailable, serve 1.0× and keep matching. A city that dispatches at the wrong price is a bad day. A city that will not dispatch is an outage.

Chip height is open demand in that cell. Chip ink density is the surge multiplier. Both are damped, so you can see the loop settle rather than snap.

10State & consistency

One trip, one driver, exactly once.

Everything so far has been soft state. The trip is not. It is the one strongly consistent object in the system, and it is small enough that this is affordable: a single-partition record keyed by trip id, with linearisable writes.

requestedquote pinned, rider waiting
→ offeredone driver, 15 s TTL
→ accepteddriver locked, rider notified
→ arrivinglive ETA fan-out begins
→ on tripdurable trace at 1 point / 15 s
→ completedfare finalised, driver released

Never double-dispatch a driver. The offer is not a message, it is a compare-and-set on the driver record:

// atomic; fails if anyone else got there first
CAS(driver.state, 'idle''offered', expect version = v)
  ok    → send the offer, arm a 15 s TTL
  fail  → drop this candidate, take the next from the k-ring

Both the matcher and the TTL reaper race on that same record, and only one wins. The board’s Offer to a second driver button attempts exactly this race — it is refused, and the refusal is logged.

Exactly-once acceptance. Mobile networks retry. The driver’s accept carries an idempotency key of (tripId, driverId, offerId); the server records the outcome against that key. A retry returns the stored result rather than re-executing. “Exactly once” here is at-least-once delivery plus idempotent application — there is no other kind.

RaceResolution
Two matchers offer the same driverCAS on driver version; loser re-searches
Driver accepts twice (retry)Idempotency key returns the first outcome
Driver accepts as the TTL expiresCAS offered → accepted with the offer id; expired offer id fails
Rider cancels while the driver acceptsSingle-partition serialisation orders them; the loser sees a terminal state and stops
Geo-index shard loses the driverIrrelevant. Trip state is not in the geo index.
The partitioning rule Draw the line between soft and hard state and never blur it. Positions, supply heat and surge are approximate, replaceable and never worth a consensus round. Trips, offers and money are exact, and they are small — 25M trips/day at ~2 KB is 50 GB/day, which a boring partitioned store handles without ceremony.
11Partitioning & failure

Shard by geography, then deal with the consequences.

Dispatch queries are local, so the partition key must be spatial. Sharding by driver id would force every k-ring search to fan out to every shard — the one thing the index exists to prevent.

KeyResult
driverId hashEvery search touches every shard. Defeats the index.
City / metro polygonSearch is single-shard. Natural blast radius. Matches how the business is run.
H3 res-2 parent cellUniform and mechanical, but cuts cities in half and needs boundary fan-out anyway.

The hot-city problem. Shards are equal in area and wildly unequal in load. The largest metro can carry 60,000 concurrent drivers and thousands of requests a minute while a small one carries two hundred. Fixes, in the order you should reach for them:

  1. Sub-shard the hot city by res-6 parent cell, with a routing table the gateways cache. A k-ring near a sub-shard boundary fans out to at most two or three sub-shards — bounded, because the ring is bounded.
  2. Split reads from writes inside the shard: ingest lands on a per-cell striped write path, dispatch reads a lock-free snapshot. They stop competing for the same locks.
  3. Only then add machines. The index is 288 MB globally — you are sharding for throughput and blast radius, never for capacity, and remembering that keeps the design honest.

Failover. Kill a district in the dock and watch it grey out. What actually happens:

failure detected missed heartbeats1.5 s
standby promoted lease / consensus1.0 s
index repopulated = one ping interval4.0 s
dispatch unavailable in that district6.5 s

In-flight trips are untouched. A trip that is already accepted lives in the trip store, and its live tracking is a socket fan-out through the gateway — neither depends on the geo index. What stops is new dispatch inside the failed district. Requests raised during the window are queued client-side and retried with the same idempotency key, so the recovery produces no duplicate trips.

What you must not do Do not fail a district over to a neighbour and let both believe they own it. Two owners of one cell means two matchers offering the same driver, and the CAS on the driver record is the only thing standing between you and double dispatch. Ownership needs a lease with a fencing token, and a shard whose lease has expired must refuse to serve — even if it still feels healthy.
12Your turn

Write the matching scorer yourself.

Every quarter-second the matcher collects, for each open request, the idle cars its k-ring found, and scores each (request, car) pair. Pairs are taken in descending score: the highest-scoring pair citywide gets its car first, then the next pair whose rider and car are both still free. Your function is that score.

Each candidate pair gives you three numbers, all in seconds: etaS — the pickup ETA if this car takes the job; riderWaitS — how long this rider has already been waiting; driverIdleS — how long this car has been empty. Return a number; highest wins.

Why pure-nearest starves people “Closest car wins” scores every pair by distance alone — so a fresh request standing next to an idle car outbids a rider who has already waited a minute one street over, every single round. In a thin-supply cell that rider keeps losing until they expire. Average ETA looks great; the p95 wait blows out. The fix is not to abandon distance — it is to let waiting escalate, so the unlucky rider eventually outbids a marginally closer newcomer.

The grader replays a fixed seeded 15-city-minute window — same city, same cars, same demand on every run — once under your scorer and once under the built-in greedy baseline, then compares the two. The first ten assignments your code makes are replayed on the plan.

13Recap

Every decision, what it bought, what it cost.

DecisionBoughtCost
In-memory geo indexSub-millisecond lookups; 288 MB for the planet; free rebuildNo durability at all; a cold shard is blind for one ping interval
H3 hexagons, res 9Uniform neighbours; ring = distance; cheap roll-ups to res 8/7Inexact hierarchy; 12 pentagons; not a legal geofence
Adaptive k-ringOne query shape for dense downtown and empty suburb alikeLatency varies with local density; needs a cap and a per-cell prior
Split hot / durable ingest3.5 TB/day of pings never touch a disk; durable volume falls 19×Two code paths; analytics see a downsampled trace, not the truth
Coalescing backpressureOverload degrades freshness instead of dropping driversUnder load the index silently ages; you must alarm on it
Batched window matchingMeasurably shorter pickups and less empty running for the same fleetUp to one window of added latency; a real solver to build and tune
Surge as a control loopSupply moves to demand without adding drivers; unmatched rate fallsDelayed feedback oscillates unless damped, rate-limited and capped
Soft state everywhere but tripsConsensus only where it is affordable and necessaryThe boundary must be policed; blurring it re-introduces the cost everywhere
Geographic shardingSingle-shard searches; blast radius = one cityHot cities need sub-sharding and boundary fan-out
Leases with fencing tokensFailover in ~6.5 s with no split-brain double dispatchA shard must refuse to serve while healthy but unleased
If you only keep one thing Most of this design is a consequence of noticing that driver positions are worth nothing in ten seconds. Once you accept that the largest data stream in the system is disposable, you are allowed to keep it in RAM, index it with something that cannot survive a restart, shard it for throughput rather than capacity, drop it under load, and rebuild it from nothing in four seconds. The expensive machinery is then reserved for the small, genuinely valuable object: the trip.

Press H to strip every overlay for a clean shot of the board, M to cycle motion modes, I for the keyboard index.