The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is Uber, really?
Strip away the brand and Uber does one deceptively simple thing: connect a person who wants a ride with a nearby car — in seconds, anywhere, all at once.
That tiny sentence hides three brutally hard problems. Where is everyone right now? Who is the best match? And how do we do this for millions of people at the same time without falling over?
What the new pieces do
- Rider Appclient
- The passenger's phone. Asks for a ride, watches the car approach, pays at the end.
- Driver Appclient
- The driver's phone. Streams its GPS location constantly and receives ride offers.
Step 1 · The skeleton
Two apps and a server
The rider’s phone and the driver’s phone can’t talk to each other directly — phones move, change networks, and can’t be trusted to coordinate fairly. How should they connect?
How do the two phones coordinate a ride?
Phones drop off networks, lie about position, and can’t see the global pool of drivers. Fairness, pricing and trust all need a referee neither phone can be.
A neutral backend holds the single source of truth, makes the matching decision, and survives any one phone going offline. This is the spine of every app you use.
Letting clients touch the database means no business rules, no auth boundary, and every phone trusting every other phone’s writes — a security and consistency nightmare.
We put a server in the middle. Both apps send requests to it; it does the thinking and remembers everything in a database. This is the client → server → database pattern under every app you use.
Why this piece earns its place
The thing this skeleton really buys is a trust boundary, and it is worth saying out loud, because every number downstream is one a phone sent us. A driver app can report a position it is not at; a rider app can ask to be billed for a trip it did not take. So the server does not merely decide, it verifies: a location ping counts only for the driver whose authenticated session sent it, and the fare is computed from what the server recorded, never from a total the client submits. The second thing to build into this first endpoint is retries. Mobile networks drop mid-request constantly, so the app will resend a ride request it is not sure landed, and a naive server cheerfully creates two. Give the request a client-generated id and make the create conditional on it, so a retry returns the original request instead of a duplicate. Both of these cost almost nothing while there is one server and one endpoint, and are miserable to retrofit once five services are calling it.
What the new pieces do
- Backend Serverbackend
- The single front door for every app request. Later it becomes an API Gateway sitting behind a load balancer.
- Databasestore
- Durable storage for things you must never lose: users, trips, receipts, payments.
Step 2 · Eyes on the road
Where is everyone right now?
To match a rider with a car, we need to know where every driver is — and they’re moving. A location from 2 minutes ago is useless. Where does a position that changes every few seconds belong?
~5M drivers each ping their GPS every ~4s. Where do you keep "current position"?
That’s ~1M+ writes/sec of data you overwrite seconds later. Disk-backed transactional storage would melt — and you’d be paying durability guarantees for data you never need to recover.
Live positions are tiny, change constantly, and don’t need to survive a crash. RAM gives microsecond writes; you keep only "now", not history. Storage matched to behaviour.
You’d have to poll millions of phones at match time, many asleep or offline. Pull-on-demand turns one fast lookup into millions of slow round-trips.
Each driver app sends a GPS ping every ~4 seconds to a dedicated Location Service. It overwrites the driver’s "current position" in a super-fast in-memory store (Redis). We don’t keep history — we only ever care about now.
Why this piece earns its place
The ping interval is the knob this step introduces, and it sets nearly everything else on the path. At every 4 seconds across ~5M drivers we are already at roughly 1.25M writes per second, and that figure is linear in the rate: halve the interval and you double the write volume, double the shard count, and double the battery and cellular data you burn on a phone clamped to a windscreen all day. Stretch it the other way and the cost lands somewhere less visible — the position we match on, the arrow on the rider’s map and the arrival estimate are all as old as the interval, so a car at motorway speed sits a long way from where the system thinks it is. The way out is to stop treating it as one global number, though the floor is never silence. A driver who is off duty is not in the pool at all and can ping as slowly as we like; an idle driver waiting for work is exactly the supply the matcher queries, so that one still has to stay inside the freshness window the geo index enforces — a cheap heartbeat, enough to keep the position live and no more. A driver mid-pickup with a rider watching the map gets the fastest rate we have. Set the rate from the driver’s state and the write budget gets spent where somebody is actually looking.
- ~5Mactive drivers
- every 4seach pings
- ~1M+pings / second
What the new pieces do
- Location Serviceservice
- Receives a GPS ping from every active driver every few seconds and writes their latest position.
- Location Storestore
- An in-memory store (like Redis) holding each driver’s current position. Fast to write, fast to read.
Back of the envelope
- 5M drivers ÷ 4s
- ≈ 1.25M location writes per second
- ~100 bytes / ping
- tiny, fixed-size — perfect for RAM
- keep 1 value per driver
- O(drivers), not O(time) — history is thrown away
- Redis does ~100k ops/s/node
- ⇒ shard the location store across ~10s of nodes
Step 3 · The clever bit
Who’s near me?
A rider taps the screen. We can’t scan all 5 million drivers to find nearby ones — that would take forever, on every single request. How do you make "who’s near this point?" fast?
A rider needs nearby drivers. How do you avoid scanning all 5M of them?
That’s 5M distance calculations per request, on every request. Linear scans don’t survive contact with scale — this is exactly what indexes exist to kill.
Latitude alone ignores longitude — nearby in one axis can be a continent away in the other. 2-D proximity needs a 2-D structure, not a 1-D sort.
A spatial index (Uber’s H3 hexagons, or a geohash/quadtree) turns "search everyone" into "look in my cell + its neighbours" — a few dozen drivers instead of millions.
We chop the world into small cells (Uber uses hexagons called H3; the textbook version is a geohash or quadtree). Every driver lives in a cell. "Near me?" becomes "who’s in my cell and the 6 around it?" — a handful of cells instead of millions of drivers.
Why this piece earns its place
The average hides the case that hurts. Spread the fleet over the cells and you get a handful of drivers per populated cell, which is the number that makes this look free — but density is nothing like uniform. An airport rank, a stadium at closing time, a city centre on a Friday night packs hundreds of drivers into one cell, and that is precisely the cell everyone is querying at once. Fixed cells make the work per query a property of the neighbourhood rather than of our code. Two defenses. First, do not treat the cell as the answer: pull candidates and cap how many get scored, because the rider does not need the best of six hundred, only a good one of a few dozen. Second, search outward instead of at a fixed radius — home cell, then the ring of neighbours, stopping as soon as there are enough candidates — so a sparse suburb widens until it finds cars while a dense centre stays tight, with nobody hand-tuning a radius per city. The write path deserves a look too: a driver near a cell boundary changes cell on almost every ping, so the index entry wants overwriting in place rather than a delete and an insert.
What the new pieces do
- Geo Indexindex
- A spatial index that answers 'who is near this point?' in milliseconds by bucketing the map into cells.
Back of the envelope
- globe → ~millions of H3 cells
- each ~1 km² at the resolution Uber dispatches on
- 5M drivers / cells
- ≈ a handful of drivers per populated cell
- query = 1 cell + 6 neighbours
- bounded work regardless of fleet size
Step 4 · The match
Pick the right driver
Five cars are nearby. Which one gets the trip? Closest isn’t always best — a car 2 min away facing the wrong way down a highway loses to one 3 min away pointed right at you. How do you choose?
The geo index returns 5 nearby drivers. Who gets the trip?
"As the crow flies" ignores one-way streets, rivers, and which way the car is pointed. The nearest dot on a map is often a longer real drive than one further out.
Dispatch optimizes the thing riders actually feel — time-to-pickup — and respects driver choice. Decline → instantly offer the next best. This is the heart of the product.
A free-for-all means four drivers wasted, race conditions on who "won", and incentives to spam-accept. Dispatch must make a deliberate, fair, single offer.
The Matching Service (Uber calls it dispatch) asks the geo index for nearby drivers, scores them on real ETA, direction, and rating, then offers the trip to the best one. If they decline, it instantly offers the next.
Why this piece earns its place
Scoring introduces two dials, and both are tuned with data rather than taste. The first is how long an offer stands before dispatch gives up and moves on. Too long and the rider watches a spinner while the second-best car drives out of range; too short and we pull offers from drivers who are mid-turn and physically cannot reach the phone, which feels to them like being skipped and is one of the quieter reasons good drivers stop accepting at all. The second is the weighting between time-to-pickup, heading and rating. Lean hard on rating and a new driver never gets a first trip, so the supply the whole product depends on never builds. Lean entirely on time-to-pickup and the optimal driver behaviour becomes parking in the busiest cell and idling there, which is not the incentive we want to ship. And put a floor under the fallback chain: after a few declines the world has moved, so re-run the nearby query rather than working down a candidate list assembled tens of seconds ago.
What the new pieces do
- Matching Serviceservice
- The brain. Given a rider’s location, finds nearby free drivers and picks the best one to offer the trip to.
Step 5 · Don’t fall over
Now do it 25 million times a day
One server was fine for a demo. At real scale it melts: too many requests, and if it dies, all of Uber goes dark. How do you carry 25M trips a day and survive a machine dying?
One server can’t hold 25M trips/day and is a single point of failure. What now?
There’s a hard ceiling on the biggest machine money buys — and it’s still one machine. When it dies, all of Uber dies with it. Vertical scaling buys time, not safety.
Replicas help reads, but the single app server is still the bottleneck and the single point of failure. You’ve scaled memory, not the thing that’s actually melting.
Run many small stateless instances behind a load balancer, split into microservices so location, matching and trips scale — and fail — independently. One dead box no longer takes the system down.
Put a load balancer in front and run many copies of each service (horizontal scaling). The single server becomes an API Gateway that routes traffic. Split work into independent microservices so location, matching, and trips can scale — and fail — on their own.
Why this piece earns its place
Splitting one server into many services trades one failure mode for a harder one. A single process is up or down; a fleet talking over a network is mostly up, and mostly up is the state that produces the confusing outages. Every hop a ride request now makes needs its own deadline and its own answer for what happens when that deadline passes, and the answer is rarely to keep waiting — a matcher blocked for seconds on a slow index lookup is holding a connection that a client retry is about to duplicate. Give each call a budget and prefer a worse answer on time to a perfect one late. The other thing to get right early is what the load balancer is actually asking. It routes around a dead box only if it can tell the box is dead, and a health check that returns OK because the process is running will happily keep feeding dispatch traffic to an instance that has lost its connection to the index. Make the check exercise the dependency the instance cannot work without, and have the instance pull itself out of rotation rather than serve errors from inside it.
- 130M+monthly users
- 25M+trips / day
- 10k+servers
Back of the envelope
- 25M trips/day ÷ 86,400s
- ≈ 290 trips/sec average
- × ~5 peak factor
- ≈ 1,500 dispatches/sec at rush hour
- + 1.25M location writes/sec
- the location path dwarfs the trip path — scale them apart
- stateless instances
- add boxes linearly; the LB spreads load and routes around the dead
Step 6 · Make it feel alive
The car on the map that moves
After matching, the rider stares at a map watching the car approach. Asking the server "where’s my car now?" every second, for everyone, would crush it. How do live updates reach the phone?
The rider wants the car to move on the map in real time. How?
Millions of phones each hammering an HTTP request per second is a self-inflicted DDoS — mostly to hear "nothing changed". Polling wastes a request for every non-event.
An always-open connection lets the server talk first — it sends a new position the instant it arrives, no request needed. Meanwhile services emit events to Kafka so they don’t block each other.
Wrong channel entirely — latency in seconds-to-minutes, rate limits, and cost per message. Push notifications and SMS are for the app being closed, not a live map.
Open one persistent connection (a WebSocket) per phone through a Realtime Gateway, so the server can push updates the instant they happen. Meanwhile every service writes events to a Kafka stream — a buffer that lets services react without waiting on each other.
Why this piece earns its place
A persistent connection is state, and it is the one piece of this system that does not come back gracefully. When a gateway instance dies or we roll a new version, every phone it was holding notices at the same instant and reconnects at the same instant — and a reconnect is not a cheap request, it is a handshake plus an authentication plus re-subscribing to whatever that phone was watching. Millions arriving in one second will take out the replacement instance and then the one after it. Two things stop that: random jitter on the client’s reconnect delay so the herd spreads itself, and deploys that drain a small share of connections at a time instead of restarting a whole tier. The second habit is being honest about what a push guarantees, which is nothing. A dropped position update does not matter; another is seconds behind it. A dropped “your driver has arrived” does. So anything the rider must not miss has to be re-readable from the trip’s own state when the socket comes back, with the push treated as an optimisation over a state the phone can always fetch again.
What the new pieces do
- Realtime Gatewaypush
- Holds an always-open connection to each phone so the server can push updates (car moved!) instantly — no refreshing.
- Event Streambus
- A firehose every service writes events to. Decouples services and feeds analytics, surge pricing, fraud checks, etc.
Step 7 · The trip & the money
From 'requested' to 'paid'
A trip isn’t one moment — it’s a journey through states, and money is involved. Lose a payment or double-charge someone and trust evaporates. How do you model the ride and handle the cash?
A trip moves requested → accepted → in-progress → completed, then bills. How do you build it?
Welding billing onto dispatch means a payment bug can stall matching, and a traffic spike on rides can drop charges. Money needs isolation, not a seat on the hot path.
A state machine makes illegal transitions impossible; SQL gives the transactional guarantees money demands; isolating payments means billing bugs can’t break dispatch. Right tool, right job.
Trips and receipts must survive crashes and be auditable for years — the exact opposite of disposable live positions. This is durable, transactional data, not hot-path cache.
A Trip Service walks each ride through a strict state machine and stores it in a reliable SQL database, sharded by city so Delhi’s load never touches London’s. A separate Payment Service handles fares and payouts so billing bugs can’t break dispatch.
Why this piece earns its place
A state machine is a guarantee only if transitions are the single way state moves and each one is conditional. The race to plan for is two genuine events landing together: the driver taps “arrived” in the same moment the rider cancels. If both handlers read the current state and then write it, the slower write silently wins, and we end up with a trip that was cancelled and is also in progress, with the billing decision attached to whichever request happened to finish last. Write each transition as an update guarded on the state we expect to find, so the loser fails loudly and can be told what actually happened, and hang every side effect — the notification, the charge — off the transition that committed rather than off the request that asked for it. The other half is making sure every trip can reach an end without a human. A driver who finishes the ride and never taps complete leaves an open trip and an unbilled fare indefinitely, so the machine needs its own timeout path into a resolved state, or we quietly accumulate rides nobody can close and nobody is watching.
What the new pieces do
- Trip Serviceservice
- Tracks a trip through its lifecycle: requested → accepted → arriving → in progress → completed.
- Payment Serviceservice
- Calculates fare, charges the rider’s card, and pays out the driver after the trip ends.
You did it
You just designed Uber.
Everything you assembled, in order
- Client → server → database — the skeleton every app shares.
- Live driver positions in fast in-memory storage (Redis).
- Geospatial index (H3) turns 'who’s near me' into a tiny lookup.
- Matching service scores drivers and dispatches the best.
- Load balancer + microservices to survive 25M trips a day.
- WebSockets push live updates; Kafka decouples services.
- Trip state machine + sharded SQL + isolated payments.
