Uber
Matching riders to drivers over real geography: indexing the world, dispatching against it, and keeping the money correct.
H3: why the world is tiled in hexagons
intermediateThe core query is deceptively simple: which drivers are near this rider. Doing it directly means comparing a point against every driver's coordinates and sorting by distance, which is fine at a hundred drivers and hopeless at scale, because the work grows with the size of the fleet rather than with the size of the neighbourhood.
The standard fix is to index space itself: divide the world into cells, put each driver in a cell, and look only at the rider's cell and its neighbours. Uber built H3 for this and open-sourced it, and the choice of shape is the interesting part.
Squares have a problem that sounds pedantic and is not. A square has eight neighbours at two different distances: four sharing an edge and four sharing only a corner, which are further away. So 'the neighbouring cells' is an ambiguous phrase, and any radial search has to decide what to do about the diagonals. A hexagon has six neighbours, all at the same distance from the centre, so expanding a search outward is unambiguous. Uber's own framing is that hexagons have one distance between a centre and its neighbours, against two for squares and three for triangles.
The second reason is quantisation error. People move continuously and cells are discrete, so a moving driver keeps crossing boundaries. Hexagons are closer to circles than squares are, which means the error introduced by snapping a moving point to a cell is smaller and more uniform in every direction.
H3 has sixteen resolutions, each cell holding roughly one seventh the area of the level above, so the same system serves a city-wide heat map and a street-level match by changing one number. It projects the sphere onto an icosahedron, twenty flat faces rather than one, which keeps cells much closer to equal area than a single flat projection would. The catch is that you cannot tile an icosahedron with hexagons alone: twelve pentagons are unavoidable, and H3 orients the solid so they fall in the ocean.
What specifically do hexagons give you that squares do not, for a radial search?
Evidence · 1 source
- 01Uber Engineering, H3: a hexagonal hierarchical spatial index
The framing quoted here, that hexagons have one distance between a cell centre and its neighbours against two for squares and three for triangles, the sixteen resolutions with an aperture of seven, and the twelve unavoidable pentagons oriented over water.
Answers are generated and can be wrong. The topic above is the reviewed version.
Dispatch: matching is an assignment problem, not a search
advancedThe naive dispatch is to give each request the nearest free driver, first come first served. It is simple, it is obviously fair, and it is measurably worse than the alternative.
Consider two requests arriving seconds apart and two available drivers. Greedy assignment gives the first request its nearest driver, which may be the same driver that was much closer to the second request, leaving the second rider with a driver several minutes away. Considering both requests together and minimising total pickup time can leave both riders better off than serving one of them optimally. That is the difference between a search, which answers one query at a time, and an assignment, which optimises a batch.
So dispatch batches over a short window, typically a few seconds, and solves a small assignment problem. The window is a real trade: longer windows produce better matches and make everybody wait, and the right length is an empirical question rather than a principled one.
Distance is also the wrong objective, which is easy to miss. What matters is time to pickup, and a driver eight hundred metres away across a river with no bridge is further, in the sense the rider cares about, than one two kilometres away on the same road. Real dispatch uses estimated travel time from a routing engine over the road network, not straight-line distance.
The system also has to reason about drivers who are not free yet. A driver two minutes from dropping off may be a better match than an idle driver ten minutes away, which means dispatch is matching against predicted future supply, not just present supply.
Why rank candidates by estimated travel time rather than straight-line distance?
Answers are generated and can be wrong. The topic above is the reviewed version.
Surge: a control loop that people can see
intermediateSurge pricing is a control loop. Demand in a cell exceeds supply, price rises, which suppresses some demand and attracts drivers from nearby cells, and the imbalance closes. H3 is what makes it computable: supply and demand are measured per hexagon, and a multiplier is set per hexagon.
The complications are almost all about the loop's dynamics rather than its economics. Set the multiplier from an instantaneous reading and it oscillates: price spikes, drivers converge, price collapses before most arrive, drivers leave, price spikes again. Any control loop with delayed feedback does this if you do not damp it, and the delay here is however long it takes a driver to physically drive somewhere.
Cell size is a genuine tension. Small cells give precise pricing and noisy estimates, because a handful of requests in a small area is not much of a signal. Large cells give stable estimates and price people incorrectly, because the busy corner and the quiet street two hundred metres away get the same number. H3's resolutions let this be tuned rather than argued about, and the answer is usually different for a dense city centre and a suburb.
There is also a boundary problem that is entirely artificial and very visible: two riders standing next to each other on opposite sides of a cell boundary should not see very different prices. Smoothing across neighbouring cells is what keeps the abstraction from leaking into someone's fare, and hexagons help precisely because 'neighbouring' is unambiguous.
What is the trade-off in choosing a smaller hexagon resolution for surge?
Answers are generated and can be wrong. The topic above is the reviewed version.
The trip as a state machine, and why that matters
advancedA trip is a long-lived object that moves through defined states: requested, matched, driver en route, arrived, in progress, completed, paid. Or cancelled, from most of them. Modelling it explicitly as a state machine rather than as a row with a handful of booleans is one of those decisions that looks like bureaucracy until the first time it saves you.
The reason is that the transitions are where all the rules live, and the rules are unintuitive. A rider can cancel before pickup, sometimes with a fee. A driver can cancel, which returns the request to dispatch rather than ending the trip. A trip can complete without payment succeeding, and that is a normal case rather than an error. With booleans, each of these becomes a condition scattered across the codebase, and the set of reachable combinations quietly becomes unknowable.
Every state change is also a message to several other systems: the rider app, the driver app, pricing, the receipt, driver earnings, fraud checks. Doing that synchronously would make the trip's write path depend on all of them being healthy, so the durable transition is recorded first and the notifications follow from it. Consumers are idempotent because at-least-once delivery means duplicates are certain rather than possible.
The genuinely hard part is that the phone is unreliable, and the trip is happening in the physical world regardless. A driver goes through a tunnel mid-trip and comes out with events queued up, possibly arriving out of order. The state machine has to reject impossible transitions rather than trust the order things arrive in: a 'trip started' message for a trip already completed is not a state change, it is a late duplicate, and the only safe response is to ignore it.
Why emit the trip events after committing the state change rather than during it?
Answers are generated and can be wrong. The topic above is the reviewed version.
Sharding by city, and what breaks at the edges
advancedTrips are overwhelmingly local. A rider in Manchester is matched with a driver in Manchester, priced by Manchester's supply and demand, and paid in pounds. That locality is a gift, because it means the data can be partitioned geographically and almost every query stays inside one partition.
Sharding by city therefore gives near-linear scaling for the common case. Each shard holds its own trips, its own driver index, its own surge state, and a busy Friday in one city does not consume capacity in another. It also gives a natural failure boundary: a shard problem is a city problem, not a company problem.
The edges are where it gets interesting, and they are real rather than theoretical. A trip from one city to a neighbouring one crosses a boundary. Airports sit outside city limits and serve several. Cities grow, and a shard that was comfortable becomes hot. Some things are genuinely global and cannot be sharded at all: a rider's account, their payment methods, their lifetime history, and any fraud signal worth having, since fraud that is invisible within one city is often obvious across several.
The practical answer is not one scheme but two. Geographic sharding for the operational data, which is local and high volume, and a separate globally-scoped store for identity and money, which is low volume and must be correct everywhere. Trying to force both into one partitioning scheme is how you end up with cross-shard transactions in the hot path.
Why does sharding by city work well for trips but not for accounts?
Answers are generated and can be wrong. The topic above is the reviewed version.
Payments: money is the part you cannot retry casually
advancedEverything up to this point tolerates approximation. A slightly worse match, a surge multiplier that is a little stale, a location a second out of date: all survivable. Payments do not work that way, because charging a rider twice is not a degraded experience, it is a defect they will remember and tell people about.
The mechanism that makes this safe is the idempotency key. The client generates a key for the payment attempt and sends it with the request. If the response is lost to a timeout and the client retries with the same key, the payment service recognises it and returns the original outcome rather than charging again. This matters because a timeout is genuinely ambiguous: the caller cannot distinguish a request that never arrived from one that succeeded and whose response was lost, and without a key the only options are to risk a double charge or to risk not charging at all.
Payment is also not one operation. A card is authorised at the start of a trip, the final amount is not known until it ends, and capture happens afterwards for an amount that may differ. Any of those steps can fail independently. The driver's earnings are a separate flow again, on a different schedule, through a different rail, and a trip can legitimately be complete, paid by the rider, and not yet paid out to the driver.
So the money is modelled as a ledger rather than as a balance field. Every movement is an immutable entry, and the balance is derived by summing them. This is slower to read and it is what makes the system auditable: you can answer why a number is what it is, and a mistake is corrected by writing a compensating entry rather than by overwriting history and destroying the evidence.
A payment request times out with no response. Why does an idempotency key resolve this?
Answers are generated and can be wrong. The topic above is the reviewed version.
Keeping millions of connections open
intermediateBoth apps need a live channel. The driver app reports location continuously; the rider app wants the car moving on the map without polling for it. That means long-lived connections at very large numbers, which is a different engineering problem from serving requests.
The first consequence is that connections cost memory whether or not they are doing anything. A thread-per-connection server runs out of memory long before it runs out of processor, which is why gateways in this shape are built on asynchronous non-blocking I/O, where an idle connection is a small object rather than a stack.
The second is that connection state has to live somewhere, because a driver's connection lands on one gateway instance and a message for that driver may arrive at another. Something has to know where each connection is, which is a registry with all the usual problems: it must be fast to read, it changes constantly as mobile clients reconnect, and it is wrong the moment a client silently disappears.
The third is that mobile networks drop connections constantly, so reconnection is the normal case rather than the exception. Clients need backoff so a network blip does not become a reconnection storm, and jitter so every client that dropped together does not come back in lockstep and do it again.
It is also worth being honest that not everything needs this. Location updates do, and a receipt does not. Sending everything down a live channel because you built one is a common and expensive mistake, since it makes the connection layer part of the critical path for things that were happily asynchronous.
Why do gateways holding millions of connections use asynchronous non-blocking I/O?
Answers are generated and can be wrong. The topic above is the reviewed version.
The data platform: the same events, twice
intermediateEvery location ping, trip transition, price calculation and payment produces events, and there are two very different consumers of them. The operational side needs them in seconds: dispatch needs current supply, surge needs current demand, the rider needs the car moving on the map. The analytical side needs them completely and correctly, but can wait: finance, forecasting, model training, regulatory reporting.
These requirements conflict enough that trying to serve both from one system serves neither well. The fast path optimises for latency and accepts approximation, because a supply count that is a second stale is fine. The slow path optimises for completeness and accepts latency, because a revenue figure that is missing a percent is not fine at all.
So the same event stream feeds both. A streaming layer maintains the aggregates dispatch and pricing read, in memory, updated continuously, deliberately approximate. A batch layer writes everything to durable storage and recomputes properly on a schedule, with late-arriving events folded in.
That last detail is the one worth remembering. Events arrive late, sometimes hours late, from a phone that was in a tunnel or a basement. The fast path has already moved on and cannot incorporate them; the batch path can, because it recomputes over a window rather than incrementing a counter. This is exactly why the batch numbers and the real-time numbers disagree, and why the batch ones are the ones finance uses.
Why can the batch layer incorporate a location event that arrives two hours late when the streaming layer cannot?
Answers are generated and can be wrong. The topic above is the reviewed version.