Skip to main content
PProfileMatch

HardProblem 22 of 36 in the system design series

Design Uber: Ride Matching with Live Locations, No Double Booking

Cover illustration: Design Uber: Ride Matching with Live Locations, No Double Booking

A ride-hailing app connects someone standing on a street corner with a driver a few blocks away, usually in under a minute. The rider sees a price, taps a button, and a car appears on the map heading toward them. Behind that simple flow sits a system that tracks millions of moving vehicles, answers proximity queries instantly, and coordinates a two-sided agreement between people who can each say no.

It is a favourite hard interview question because it combines three distinct challenges in one product. There is a write-heavy geospatial workload from constant GPS pings, a latency-sensitive search for the closest available cars, and a correctness problem: two riders requesting at the same moment must never both be promised the same driver.

Strong answers recognise that these concerns want different tools. Location data is ephemeral and belongs in memory, matching needs short-lived locks that expire on their own, and the ride itself is a durable record with strict state transitions guarded by the database.

This is an interview practice design for a Uber-like service, built from first principles. It is not a description of how Uber is actually built.

Functional Requirements

Four user-facing capabilities define the scope. Before committing, a rider enters a pickup point and destination and gets back a price and an estimated arrival time. When the rider confirms, the platform searches for an available driver close by. That driver receives an offer on their phone and chooses to accept or reject it.

After a successful match, both parties follow the journey on a live map from pickup to drop-off. Payments, ratings and scheduled rides all matter in a real product, but each is a substantial system on its own, so we set them aside. That keeps attention on location tracking and matching, which is where the interesting design decisions live.

Fare estimate

Enter pickup and drop-off; see the price and the ETA.

price · ETA

Request a ride

The rider confirms, and we find a nearby driver.

match in seconds

Accept or decline

The driver gets the request and accepts or declines it.

driver app

Track the trip

Both see live location from pickup to drop-off.

live updates

  • Out of scope today: payments, ratings and scheduled rides

Non-Functional Requirements

Speed shapes the rider experience. Searching for nearby drivers should complete in under a second, and the full match, including the driver's decision, should land within about a minute. Consistency is non-negotiable: assigning one driver to two rides creates a broken experience for at least one rider and erodes trust in the platform.

The write load is unusual. Every online driver reports their position every few seconds whether or not they have a passenger, so location updates vastly outnumber everything else. Availability must hold during spikes like New Year's Eve or the end of a big match, when demand can jump to ten times normal, which is exactly when riders most need the service to work.

Fast matching

Nearby-driver search is instant; a match within a minute.

search < 1 s

Consistent matching

A driver is never assigned to two rides at once.

no double booking

Heavy location writes

Every online driver sends GPS every few seconds.

500K updates / s

Available at peaks

New Year's Eve and match nights can be 10× normal.

surge traffic

Scale Estimation

Assume around 2 million drivers online at peak, each sending a GPS update every 4 seconds. That works out to roughly 500,000 location writes per second. Rides are a much smaller stream: assuming 30 million trips per day gives only about 350 ride requests per second on average.

Now size the live state. Each driver's current position plus a little metadata is about 100 bytes, so the entire fleet's latest locations come to roughly 200 MB. That is small enough to sit comfortably in memory on a single node, with replicas for safety. The conclusion drives the architecture: the location firehose dominates the load, and because only the latest point matters for matching, the geo index should live in RAM rather than on disk.

2million

Drivers online at peak

500K / s

Location updates

one ping every 4 s each

30million

Rides per day

≈ 350 requests / s avg

200MB

Live location state

2M × ~100 B → fits in RAM

Location pings dominate, not rides → keep the geo index in memory

Core Entities & API

Four entities carry the domain: Rider, Driver, Ride and Location. The Ride is the durable business object that must survive crashes and audits; Location is high-frequency, short-lived data that is overwritten constantly, so the two are stored in very different places.

Booking happens in two calls. POST /fare takes pickup and destination and returns a fare_id, price and ETA, which lets the server lock in a quote. POST /rides with that fare_id creates the ride in a MATCHING state. Drivers stream their position through POST /drivers/location with latitude, longitude and timestamp. Drivers advance a trip with PATCH /rides/{id}, passing an action such as accept, decline or arrive, and the server validates whether that transition is allowed from the current state.

Core entities

RiderDriverRideLocation

API

  • POST /fare { pickup, destination }→ { fare_id, price, eta }
  • POST /rides { fare_id }→ { ride_id, status: MATCHING }
  • POST /drivers/location { lat, lng, ts }→ 200 ok
  • PATCH /rides/{id} { action: accept | decline | arrive }→ { status }

High-Level Design

Riders reach the backend through an API gateway that authenticates requests and routes them. The ride service owns quoting and ride creation, persisting every ride and its current status in Postgres. Separately, driver apps send GPS updates to a location service built to absorb hundreds of thousands of writes per second, and it overwrites each driver's entry in an in-memory geo index backed by Redis.

When a booking arrives, the ride service hands it to a matching service. Matching asks the geo index for available drivers near the pickup, picks the best candidate, and claims that driver through a short-lived lock in Redis before pushing an offer to their app. A yes from the driver finalises the assignment; otherwise the lock is dropped and the next candidate gets the offer. Keeping location ingestion and matching as separate services means a surge in pings never slows down ride creation.

ride + stateGPS every 4 supdatefind drivernearbylock driverride offerRider appiOS · AndroidAPI gatewayauth · routingRide servicefares · ride stateRides DBPostgresDriver appGPS · offersLocation service500K pings / sGeo indexRedis · geohash / H3Matching servicefind + lock driverDriver locksRedis · 10 s TTL
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Locations & Geo Index

A relational table with latitude and longitude indexes is the familiar starting point, but half a million updates per second would hammer it, and B-tree indexes handle two-dimensional proximity poorly. An in-memory quadtree subdivides dense areas into finer squares, which suits uneven city density, yet constant driver movement keeps forcing nodes to split and merge.

Fixed grid cells are the better fit. Geohash or H3 assigns every point to a cell ID, so an update is just moving a driver from one cell to another, and a search reads a handful of cells. Redis offers this out of the box: GEOADD stores points encoded as geohashes and GEOSEARCH returns those within a radius. With H3 hexagons, we query the rider's cell plus the surrounding ring, which catches a driver parked just over a boundary.

Only the latest position is held in memory. The complete trail of pings is published to Kafka and landed in cheap storage, where billing, fraud checks and analytics can use it without touching the hot path.

SQL lat / lng index

  • + Simple, familiar
  • − 500K writes / s is a lot
  • − 2-D range queries are slow

Quadtree in memory

  • + Adapts to density
  • − Rebalancing on every move

Geohash / H3 cellsChosen

  • + Cheap updates
  • + Fast nearby-cell lookup
  • − Edges need neighbours
  • Redis GEOADD / GEOSEARCH — geohash under the hood
  • H3 hexagons: search the rider's cell + one ring of neighbours
  • Only the latest point in memory; full trail → Kafka → cold storage

Deep Dive 2 — Matching Without Double Booking

Matching starts by pulling the closest available drivers from the geo index and ranking them by estimated time to pickup instead of raw distance, since a driver across a river may be close as the crow flies but far by road. The top candidate is then claimed with SET NX on a Redis key and a 10-second TTL. When two ride requests race for the same driver, only one write succeeds and the loser moves on to its next candidate.

With the lock held, the offer is pushed to the driver's phone and the matcher waits up to ten seconds. An accept is committed to the rides database through a conditional write that checks the driver has no other active ride, so the database remains the final authority. A decline or silence releases the lock (or lets it expire) and the next driver is tried. Ride requests sit in a durable queue throughout, so if a matcher crashes, another instance picks up the work and any orphaned locks clear themselves after their TTL.

  1. 1Find candidatesNearest available drivers, ranked by ETA
  2. 2Lock one driverSET lock NX with a 10 s TTL — only one ride wins
  3. 3Send the offerPush to the driver app; wait up to 10 s
  4. 4Accept → commitConditional update in the rides DB
  5. 5Decline or timeoutRelease the lock; offer the next driver
  • Ride requests sit in a durable queue → a crashed matcher loses nothing

Deep Dive 3 — Ride State Machine

A ride follows a short, explicit lifecycle: REQUESTED, MATCHING, ASSIGNED, ARRIVED, IN_TRIP and COMPLETED, with CANCELLED reachable from the early stages. Writing these down prevents ambiguous situations like a ride being both cancelled and in progress.

Each transition is executed as UPDATE ... WHERE status = expected. If the current state is not what the caller assumed, the update affects zero rows and the request is rejected, which blocks illegal jumps and makes retries harmless. Timers push stuck rides forward: when several minutes pass without an acceptance, the status flips to NO_DRIVERS and the rider is informed. Every successful change emits an event, so notifications, billing and ETA updates react to the stream rather than polling Postgres. During the trip, the driver's pings are relayed to the rider over a WebSocket so the car glides across their map.

  • A small set of clear states

    REQUESTED → MATCHING → ASSIGNED → ARRIVED → IN_TRIP → COMPLETED, plus CANCELLED.

  • Every transition is a conditional update

    UPDATE … WHERE status = expected → no illegal jumps, safe retries.

  • Timeouts move rides forward

    No driver after a few minutes? Move to NO_DRIVERS and tell the rider.

  • Each change publishes an event

    Notifications, billing and ETAs react to events, not to polling.

  • Live tracking during the trip

    Driver pings are forwarded to the rider over a WebSocket.

Trade-offs

Frequent pings give accurate positions but cost bandwidth and battery; letting idle or parked drivers report less often cuts load without hurting matching. Holding the geo index in memory is fast but volatile, which is acceptable because every driver repopulates their position within seconds after a restart.

Pairing a Redis lock with a conditional database write gives speed in the common case while keeping Postgres as the source of truth if the cache misbehaves. Ranking by road-network ETA chooses better drivers than straight-line distance, at the cost of extra computation per request. In short: keep live positions in RAM, claim a driver before offering, and guard every status change with a conditional write.

  • Ping often vs ping less

    Every 4 s is accurate but heavy; parked drivers can ping less often.

  • In-memory geo index

    Very fast but volatile — fine, drivers re-send within seconds.

  • Redis lock + DB check

    The lock is fast; the database stays the final source of truth.

  • Nearest vs best ETA

    Road-network ETA picks better drivers, but costs more to compute.

Index locations in memory. Lock before you offer. Guard every state change.

Key takeaways

  • Location pings, not rides, dominate load; keep only each driver's latest point in an in-memory geo index.
  • Geohash or H3 cells make updates cheap and nearby search a read of a few cells plus neighbours.
  • Claim a driver with a short-TTL Redis lock before offering, then commit with a conditional DB update.
  • Durable request queues and expiring locks let another matcher recover from a crash safely.
  • Model rides as a state machine with conditional transitions and publish an event on every change.

Interview questions

How does a ride-hailing app find nearby drivers quickly?

It divides the map into cells using geohash or H3 and keeps each driver's latest position in an in-memory index such as Redis geo sets. A search reads the rider's cell plus neighbouring cells, so drivers just across a boundary are included. Candidates are then ranked by estimated time to pickup on the road network rather than straight-line distance.

How do you prevent a driver from being assigned two rides at once?

Before sending an offer, the matching service claims the driver with an atomic set-if-not-exists on a Redis key with a short TTL, around ten seconds. Only one concurrent request can win. When the driver accepts, the assignment is committed to the database with a conditional update that succeeds only if the driver is still free, so the database remains the final guard.

How do you handle 500,000 driver location updates per second?

Send pings to a dedicated location service that overwrites only the latest position per driver in an in-memory geo index. The live state for two million drivers is roughly 200 MB, which fits in RAM. The full history of pings is streamed to Kafka and archived in cheap storage for billing and analytics, keeping the hot path small and fast.

What happens if the matching server crashes during a ride request?

Ride requests are stored in a durable queue, so the request is not lost; another matching instance picks it up and retries. Any driver lock held by the crashed server has a short TTL and expires on its own, freeing that driver for other rides. Because ride state changes use conditional updates, the retry cannot corrupt the ride's status.

Why model a ride as a state machine in system design interviews?

Explicit states such as MATCHING, ASSIGNED, IN_TRIP and COMPLETED make it clear which actions are legal at each point. Implementing each transition as a conditional update prevents illegal jumps and makes retries safe. Publishing an event on every change lets billing, notifications and ETA services react without polling, and timeouts can move stuck rides into states like NO_DRIVERS.

Keep practising