Ask most engineers how Uber works and you get a picture that is basically a map with some dots on it. A rider taps a button, the nearest driver lights up, the two are connected, and the trip begins. That mental model is not wrong so much as it hides every part that is actually hard. The dots are moving. The question you are asking about them is spatial, not a lookup by id. And the answer you want is not the nearest driver by straight-line distance, it is the driver who can actually get to the rider fastest, given everything else happening in the city at that second.
This is how I would design that system if it landed on my desk, worked the way I would want a staff engineer to work it in a design review, out loud and from first principles. I start from the requirements and the arithmetic, because the numbers decide the architecture before any component does. Then I go one hard problem at a time: how to index a moving world so proximity is cheap, why hexagons beat squares, how the location firehose stays off the durable store, how the matcher reads and scores candidates, why it scores on time to arrive rather than distance, how a driver is actually assigned, and how two riders are stopped from grabbing the same car. After that come surge pricing, storage, failure behavior, and the patterns that transfer to systems that have nothing to do with cars.
Uber is not a map with dots on it. It is a spatial index over a moving world, and almost every hard decision in the design follows from that one fact.
One note on numbers before I start. Every latency, throughput, or storage figure here is an industry-typical estimate meant to drive the sizing, not a measured production metric from any specific company. The shape of the reasoning is what transfers, not a figure I cannot verify.
Start with the requirements, not the map
The feature list for a ride-hailing service fits in three verbs. A rider requests a car at a pickup point. The system matches and dispatches a driver. Then everyone tracks the trip through to completion and payment. Everything a user touches is one of those three, and it is tempting to start drawing boxes for them immediately.

The three features are trivial to state. The non-functional column, match latency, availability, and location volume, is where the design actually lives.
The design does not live in the feature list. It lives in the non-functional requirements, and I write those down explicitly before drawing anything. The match must feel fast, because a rider watching a spinner is a rider one tap away from a competitor's app. I would target single-digit millisecond spatial lookups and an end-to-end match in a few seconds. Reads and trip state must be highly available and durable, because a rider whose phone dies mid-trip has to see the correct state when it reconnects, and a trip is the record of who owes whom money. Writes of location, by contrast, can tolerate loss, because a driver's position is overwritten every few seconds anyway. And the system has to absorb an enormous and continuous volume of location updates, up to a million drivers each reporting every few seconds, without that firehose ever touching the parts that must not lose data. State those constraints first and the rest of the design reads as a series of answers to them.
Do the arithmetic before the architecture
Before choosing a database or an index, I do the back-of-envelope math, because for this system the important ratio is not reads to writes, it is ephemeral writes to durable writes. Suppose a million drivers are online at peak, each sending a location update every four seconds. That is roughly two hundred and fifty thousand location writes per second, arriving all day, every day.

A million drivers pinging every few seconds produce a location write rate that dwarfs trip writes by roughly 170 to 1, and that gap is the whole design.
Now the durable side. A service at this scale does on the order of twenty-five million trips a day, which averages under three hundred trip creations per second and peaks somewhere around fifteen hundred per second. So at peak, location writes outnumber durable trip writes by roughly a hundred and seventy to one, and against the daily average the gap is closer to a thousand to one. Two more numbers close the picture. The live position of every driver, an id, a coordinate pair, a cell, a state, and a timestamp, is only a couple of hundred bytes, so a million of them is around two hundred megabytes, which fits in memory on a single machine with room to spare. Five years of trip records, on the other hand, is tens of billions of rows and roughly a hundred and fifty terabytes, large enough to demand partitioning but never the cost that binds the design. The arithmetic points somewhere specific. The volume, and therefore the hard engineering, lives in the disposable location firehose, not in trips and not in storage. That is where the budget goes.
Why real-time matching is genuinely hard
With the numbers in hand, it is worth naming why this problem resists the obvious solution, because three forces press on it at once and each pushes the design in a different direction.

Three forces at once: the world is moving, the query is spatial, and the decision has to be good rather than merely nearest.
The first force is that the world moves. This is not a database of parked cars. Hundreds of thousands of drivers are in motion, each rewriting its position every few seconds, so the dataset I am querying changes continuously. A driver who was the best candidate when the rider tapped may be a block past the pickup by the time I decide. Any design that assumes positions hold still, even for a second, is designing for a world that does not exist. The second force is that the query is spatial. I am not asking for the row with a given key, which every database answers in constant time. I am asking for the drivers within a few minutes of a point, and a normal sorted index cannot express that. A B-tree on latitude narrows to a thin band at the rider's latitude and then hands back every driver at that latitude across the whole map, almost all of them far away east or west, with no way to constrain longitude in the same pass. One ordered axis cannot express two-dimensional nearness. The third force is that the decision must be good, not merely correct. The geometrically nearest car might be across a divided highway with no turnoff for a kilometer, or about to finish another trip. Sending it can strand a whole neighborhood while a car thirty seconds further out would have served everyone. Every section below is a response to one of these three.
The shape of the system
Before the hard parts, here is the map of services, because it is hard to reason about matching without knowing what talks to what.

Rider and driver apps reach a gateway that fronts a trip service, which coordinates location, matching, routing, pricing, and payment, with a durable trip store alongside.
Two mobile apps sit at the edge and behave differently. The driver app is mostly a writer, pushing location on a steady heartbeat. The rider app is mostly a reader until the moment it makes a request, at which point it becomes the trigger for everything else. Both connect through an API gateway that terminates the encrypted connection, authenticates the device, rate limits, and routes each call to the right backend, so nothing behind it reimplements authentication. Behind the gateway, a trip service acts as the coordinator that owns the concept of a trip and orchestrates the others. It leans on a location service that ingests pings and answers spatial queries, a matching service that turns a request into an assigned driver, a routing service that computes travel times over the road network, a pricing service that decides fares and surge, and a payment service that captures money at the end. Alongside them is a durable trip store, the system of record for every trip. The division that matters most, and the one I will keep returning to, is that the fast-moving location data and the durable trip data live in different places for good reasons. Conflating them is the classic mistake in this design.
Indexing a moving world so proximity is cheap
Start with the query that runs most often: find the drivers near this point. Done naively, that means computing the distance from the rider to every driver in the city on every request, work that grows in direct proportion to the number of drivers, which is hopeless at hundreds of thousands of them. The fix is the same idea that makes any lookup fast, an index, but one built for spatial questions.

Cover the map in cells so find nearby drivers becomes a lookup of a handful of cells rather than a scan of every driver. H3, S2, and geohash are three schemes for doing it.
The trick is to stop treating position as two continuous numbers and instead chop the surface of the earth into discrete cells, each with an identifier. On every update, a driver is filed into the cell that contains them. Now finding nearby drivers is not a scan. I compute the rider's cell, collect the drivers filed in that cell and its immediate neighbors, and the cost depends only on how many drivers sit in those few cells, not on the citywide total. An expensive geometric question has become a cheap lookup by cell id, exactly what a hash table answers instantly. Several schemes do this. Geohash, the oldest, interleaves the bits of latitude and longitude and base32-encodes them, so a shared prefix means physical closeness, with the ugly edge that two points adjacent on the ground can share almost no prefix if they straddle a boundary. Google's S2 projects the sphere onto the six faces of a cube and orders cells along a space-filling curve, so nearby cells get nearby ids. Uber built and open-sourced H3, which tiles the world in hexagons, and that is the scheme I would build on. The reason is worth its own section.
Why hexagons
H3's choice of hexagons is not aesthetic. It comes from a real defect in the obvious alternative, the square grid.

In a hexagonal grid every neighbor sits at the same distance from the center. In a square grid the four diagonal neighbors are farther than the four edge neighbors.
Take a square grid and pick a cell. It has eight neighbors, but they are not all the same distance from the center. The four that share an edge are one cell-width away. The four that share only a corner are farther, by a factor of the square root of two, about 1.41. So when I expand a search by one ring of neighbors, the diagonal directions reach noticeably farther than the cardinal ones, and my notion of nearby comes out lopsided, stretched along the diagonals. For a system whose entire job is judging nearness, a grid that quietly distorts nearness is a bad foundation. A hexagon has six neighbors and every one of them shares an edge, so every neighbor sits at the same distance from the center. Expanding a search ring outward grows evenly in all directions, which makes distance approximations and any smoothing over cells behave sensibly. There is one honest caveat: you cannot tile a sphere with hexagons alone, so the geometry forces a small number of pentagons, twelve of them in H3, placed out in the oceans where they disturb almost nothing. A second, subtler caveat is that because the hexagons lie over a spherical projection, real cells vary slightly in size, so the equal-neighbor property is very close but not mathematically exact, which is all the matching path needs.
The location firehose, kept off the durable path
Now the write side, where the world is moving force hits hardest. Two hundred and fifty thousand small writes per second arrive continuously. The instinct to write each one straight into the main database is a mistake that will take the database down, and it also misunderstands what the data is for.

Driver pings flow through a streaming layer into an in-memory geo index, the hot path that is fast and can afford to lose a ping. Trip records take a separate durable path.
The key insight is that a driver's current location is not a fact I need to keep on the matching hot path. It is a fact I need right now and will overwrite in a few seconds anyway. Its value for matching decays almost immediately. So it belongs on a hot path optimized for speed and freshness, not durability. Updates flow through a streaming ingestion layer that absorbs bursts and land in an in-memory geospatial index keyed by cell, which is what the matcher reads. That index is deliberately approximate. If one ping in a sequence is dropped under load, nothing is lost, because another arrives in seconds. The streaming layer uses bounded buffers, so when input outruns the index it sheds the oldest pings rather than growing without limit, and shedding is safe precisely because a fresher ping is only seconds behind. Location history is still valuable, so it is archived asynchronously off the hot path into cheap storage, where it feeds billing, fraud checks, dispute resolution, and the training of ETA models. Contrast the durable path. When a trip is requested, accepted, started, and completed, those facts must never be lost, so trip records go through a separate write path into the durable trip store with the guarantees a system of record demands. Splitting the two lets each be sized for the traffic it carries, and it is the single most important structural decision in the whole design.
The matching read path
With drivers filed into an in-memory cell index, the read path for a match becomes clear, and it has a deliberate order to it.

A request resolves to a cell, the matcher reads candidates from that cell and its neighbors, then scores only that short list. The cheap filter runs before the expensive one.
A rider requests a trip. The matcher computes the rider's cell, then gathers candidate drivers from that cell and the surrounding ring of neighbor cells, which is why hexagons earned their place, since the ring is uniform in every direction. If that pulls in too few candidates because supply is thin, the matcher widens the ring by another layer and tries again, trading a slightly worse average match for actually having someone to send. If a dense downtown cell pulls in too many, it can start with a tighter radius. Either way the cell index has turned an unbounded citywide scan into reading a small, controllable set of cells. The ordering here is the point. The candidate set is filtered to the right neighborhood, from hundreds of thousands of drivers down to a few dozen, before any expensive computation runs. Only then does the costly per-candidate scoring run, on that short list. Doing it the other way, scoring everyone and then filtering, would waste almost all of the scoring work. Cheap filter first, expensive scoring second, is a pattern worth internalizing well beyond this system.
Scoring on the real metric, not the convenient one
Here the third force, the decision has to be good, takes over. I have a short list of nearby drivers. The lazy move is to rank them by straight-line distance and pick the closest, and it produces bad matches often enough to matter.

Straight-line distance says the driver across the river is closest. On the road graph that trip is a long detour to a bridge, while a driver farther up the same street is a couple of minutes out.
The canonical example is a driver two hundred meters from the rider but on the other side of a river with no bridge nearby. Straight-line distance calls that driver the winner. In reality that car has to drive down to a crossing and back up the far side, maybe ten minutes. Meanwhile a driver six hundred meters away on the same street as the rider, with no obstacle between them, is two minutes out. Distance picks the wrong car. What I actually care about is time to arrival on the real road network, which respects rivers, one-way streets, on-ramps, and current traffic. So the matcher scores candidates by estimated time of arrival, computed by the routing service over a graph where intersections are nodes and road segments are weighted edges whose weights reflect length, speed limits, and live traffic. Computing shortest paths over a city-scale graph for every candidate on every request from scratch would be too slow, so routing systems precompute and cache heavily, using techniques like contraction hierarchies and a fast lower-bound estimate to prune candidates before running an exact route. The principle underneath is what matters: optimize the true cost function, travel time, not the convenient proxy, distance, even though the proxy is far easier to compute. The gap between the two is exactly where straight-line matching fails.
How a driver actually gets assigned
Scoring gives a ranked list. Assigning is its own small protocol, and it explains something every rider has seen.

The dispatcher offers the trip to a driver, waits a few seconds, and on a decline or timeout moves to the next candidate. That retry walk is the still finding your driver spinner.
A driver is not a resource I can silently allocate. A human has to accept. So the dispatcher offers the trip to the top-ranked candidate and waits a short window, a handful of seconds, for a response. If the driver accepts, the match is made. If the driver declines, or the window expires with no answer, the dispatcher moves to the next candidate and offers again, walking down the ranked list until someone accepts or the list is exhausted, at which point the search widens or the rider is told no cars are available. That is exactly the still finding your driver state in the app. It is not the system being slow, it is the system walking a list because the first choices did not take the offer. A purely per-request greedy loop, where each rider grabs its own best driver with no thought for the rider one street over, is locally optimal and globally wasteful. So in practice the dispatcher batches requests over a short window and solves the assignment across all waiting riders and nearby drivers together, deliberately giving one rider a slightly worse individual match so the whole batch does better, with a higher match rate and lower total wait. The offer loop then executes the assignment the batch solver produced.
Two riders, one driver
The offer loop hides a nasty concurrency bug. Two riders in the same area request at nearly the same instant, both matchers read the cell index, both see the same idle driver at the top of their lists, and both send an offer. Now one car has two trips, or one rider holds an offer that is already gone. Correctness here is not optional.

Both matchers pick the same idle driver. An atomic claim, a conditional update on the driver, lets only one offer hold the driver, and idempotent trip creation stops a retry from making a second trip.
The fix is an atomic claim on the driver. Before an offer goes out, the dispatcher must acquire an exclusive hold on that driver, and the acquisition has to be atomic so only one of two racing requests can win. In practice this is a conditional update or a short-lived lock: flip the driver's state from available to offered only if it is currently available, an operation the data store executes atomically, so the second matcher's attempt fails and it moves to its next candidate. The hold carries a timeout, so if the offered driver never responds and the offering request dies, the driver is not stuck locked forever, it reverts to available. For the claim to be safe, the driver's state must live in a store that gives a linearizable conditional update on that driver's key, typically by keying the driver to a single partition so both racing requests contend on the same authoritative record rather than on two eventually-consistent replicas that could each say yes. Two more details make it solid. Trip creation must be idempotent, keyed on a client-generated request id, so a network retry does not produce two trips. And the driver's state transitions, available to offered to assigned, are the actual source of truth for whether they can be matched, not the possibly-stale position in the cell index. Exactly-once is neither achievable nor needed here. At-most-once, from the atomic claim, is.
The data model and where it lives
The storage is not one database, it is three, because this system has three access patterns that share almost nothing.

A durable trip record keyed by trip id, with surge locked at request time and the fare settled at the end, alongside an in-memory index keyed by cell and a columnar analytics store.
The trip record is the system of record. It holds the trip id, the rider and driver ids, the current state, the pickup and dropoff points, timestamps, the surge multiplier locked in when the rider requested, and the fare computed at the end. The only hot-path access is get-by-trip and update-this-trip, with no cross-trip joins, so I key it on the trip id and colocate records by region, which makes a wide-column or key-value store that partitions cleanly the right home rather than a relational engine whose joins I never use. The live index is the opposite. It is keyed by H3 cell, its value is the set of driver states in that cell, it lives in memory, and it is rebuilt continuously from the ping stream. Crucially, the index is never the source of truth for a trip. The driver's authoritative state lives in the durable store, and the index is a fast, disposable projection of position. The third store is for analytics. Trip and location history land in a columnar, OLAP-style store built for aggregate reads, which is what pricing models, fraud analysis, and reporting query, and it is never on the path of a live request. Forcing get-by-key trips, by-cell proximity, and aggregate analytics into one database would serve none of them well. Let each access pattern pick its own store.
Surge pricing falls out of the same data
Surge pricing feels like a separate product feature, but it is really just another read over the spatial data I already have, and seeing it that way demystifies it.

For each cell, compare open requests against available drivers. When demand outstrips nearby supply, the multiplier for that cell rises, which both rations demand and pulls supply toward it.
For any given cell I already know two things: how many drivers are available in it and its neighbors, and how many riders are requesting in it. The ratio of demand to supply in that local area is the signal. When many riders want a car and few drivers are near, that imbalance is what a price multiplier responds to. Raising the multiplier for that cell does two things at once. Some price-sensitive riders decide not to request now, which lowers demand toward the available supply. And drivers, who can see where surge is active, move toward the higher-earning area, which raises supply. The multiplier is a control signal on a local supply-demand imbalance, computed per cell, and the cell grid I built for matching is exactly the structure that makes it computable. It is spatial and local, not a citywide knob: one neighborhood around a stadium after a concert can be deep in surge while a cell two kilometers away is at normal pricing. In practice the multiplier is smoothed over a short window and damped rather than snapped to the instantaneous ratio, because a loop that reacts to every twitch of supply and demand oscillates, with drivers chasing a surge that has already cleared by the time they arrive. This is a good example of one well-chosen data structure paying for itself across several features.
What breaks, and how it degrades
A staff-level design is judged on its failure behavior, not its happy path. Every part of this system can fail, and the goal is not to pretend otherwise but to degrade gracefully so a rider still gets a car. This is also where the geographic sharding pays off, because I shard the live index by region and cell, so no single node owns the world and a match query, being inherently local, usually lands entirely inside one shard.

When the location index is stale, a region shard is down, or the ETA service is slow, the system widens the search, falls back to straight-line ranking, or queues, rather than failing outright.
Consider a stale location index, where pings have stopped arriving from some drivers because of a network problem, so their recorded positions are old. Matching on stale positions produces offers to drivers who are not where I think, and those offers time out and the dispatcher walks down the list. The mitigation is to treat freshness as a signal: prefer recently-updated drivers, discount or skip those whose last ping is too old, and lean on the offer loop, which already tolerates declines and timeouts, to absorb the error. A wrong candidate costs a few seconds, not a failed request. Now take a region shard going down. Because sharding is geographic, the blast radius is one region, not the world, which is the whole point of partitioning this way. Requests fail over to a standby or a rebuilt index, and the honest degradation while that happens is higher latency or a brief no cars available in that area, not a global outage. The interesting failure is the ETA service being slow, because it sits on the critical path of scoring. Blocking every match on a slow router would stall dispatch citywide, so the degradation is to fall back to the cruder metric I can always compute locally, straight-line distance, and rank on that until routing recovers. Matches get worse, they do not stop. And when demand simply overwhelms supply, the last resort is to queue the request and hold the rider in a waiting state rather than reject outright. The thread through all of these is the same: widen the radius, fall back to a simpler ranking, or queue rather than reject.
The patterns that transfer
Step back from Uber specifically, because the reason this system is worth designing carefully is that its core decisions show up far outside ride-hailing, and you will build many systems that share its shape.

Four transferable patterns: build the index that fits the query, split the hot approximate path from the durable one, optimize the real cost function, and partition along the query's own axis.
The first pattern is to build an index that fits the query. A normal index could not answer who is nearby cheaply, so the design imposed a spatial cell grid whose shape matches the shape of the question. Whenever your hot query is slow, the real problem is often that your data is organized for a different question than the one you are asking, and the fix is a derived structure built for the query you actually run, not a faster loop over the wrong layout. The second pattern is to split the hot, approximate path from the durable, must-not-lose path. Location pings are high volume and disposable, so they live in a fast in-memory index that can drop a message. Trip records are low volume and sacred, so they live in a durable store with strong guarantees. Many scaling problems dissolve the moment you notice that two kinds of data with different value and volume are being run through one pipe. The third pattern is to optimize the real cost function, not the convenient proxy. Distance is easy to compute and it is the wrong metric. Travel time is expensive and it is the metric that produces good matches, so the system pays for the routing. The fourth pattern is to partition along the axis your queries already follow. Matching is local, so sharding by geography keeps almost every query inside one shard and cross-partition work rare. Learn to see which of these levers your own bottleneck sits on and the design stops being a memorized answer and becomes a method.
I teach system design this way, from first principles with real diagrams and the trade-offs that only surface in production, as an interactive course at systemdesign.academy. The foundation lessons are free and need no signup.
Read the free lessons: https://systemdesign.academy
Top comments (0)