Walkthrough28 min

Walkthrough: Ride Sharing

Proximity search with geohashing/quadtrees, high-frequency location updates, and the matching flow.

"Design Uber" has two hard problems living in one question: a geospatial problem (find nearby drivers, fast, under a firehose of location updates) and a concurrency problem (never offer one driver two rides at once). Mid-level interviews usually scope it down to exactly those two.

Step 1: Requirements

Functional:

  1. Riders get a fare estimate, then request a ride.
  2. The system matches the rider with a nearby available driver, who can accept or decline.
  3. Drivers continuously report their location.

Non-functional:

  • Matching feels fast (a few seconds end to end).
  • No double-dispatch: a driver never receives two simultaneous offers, and a ride never goes to two drivers — this is a strong-consistency requirement; say it up front.
  • Scale: millions of active drivers pinging locations — on the order of 1M+ location writes/sec at peak.

Step 2: Core entities

Rider, Driver, Ride (with a status), Location (driver_id, lat, lng, updated_at).

Step 3: API

POST  /v1/fare               { pickup, destination } -> { estimate }
POST  /v1/rides              { pickup, destination } -> { ride_id }
PATCH /v1/rides/:id          { action: accept | decline }
POST  /v1/drivers/location   { lat, lng }   (or over a WebSocket)

The ride moves through a state machine: requested → matched → accepted → in-progress → completed. Modeling it as explicit states keeps every later decision honest.

Step 4: High-level design

Services with a reason for each: a Location service ingesting driver pings, a Matching service pairing riders to drivers, a Ride service owning the state machine, and notifications for offers. The interesting choices are inside Location and Matching.

Why a plain database dies. Storing lat/lng columns in Postgres fails twice at this scale: 1M+ writes/sec swamps a single node (~10k+ sustained writes/sec is where sharding talk starts), and "drivers within 2km" on raw coordinates is a full-table scan — a normal B-tree index can't answer a 2D radius query.

The fix: a geospatial index in memory. The standard mid-level answer is Redis geospatial commands, which are built on geohashing — encoding a 2D location into a string where nearby points share prefixes, so proximity becomes prefix lookup:

GEOADD   drivers <lng> <lat> driver:42     -- driver ping
GEOSEARCH drivers FROMLONLAT <lng> <lat> BYRADIUS 2 km ASC

Redis absorbs the write rate in memory, and stale drivers expire via TTL cleanup. Name the alternatives in one breath: a quadtree (a tree that subdivides dense areas into smaller cells — great for uneven density) and PostGIS (Postgres's geospatial extension — fine at smaller scale). One edge case worth mentioning: two drivers can be meters apart across a geohash cell boundary, so searches check neighboring cells too.

Deep dive 1: Taming the write firehose

1M writes/sec is worth attacking, not just absorbing. Adaptive ping intervals: a driver moving at highway speed pings every second; a parked driver every 30 seconds — the client decides based on speed and displacement. Add batching at the ingestion edge. Cutting the write volume 5–10x with a client-side rule is the kind of pragmatic move interviewers reward.

Deep dive 2: Preventing double-dispatch

Two match requests find the same nearest driver at the same moment. Walk the ladder:

  • Bad: check "is driver free?" in app code, then send the offer. Two matchers interleave; both send. Classic race.
  • Good: an atomic DB update — UPDATE drivers SET status='offered' WHERE id=? AND status='available' — with a cron to reset offers that time out. Correct, but the cleanup cron is clunky and slow to reclaim.
  • Great: a Redis distributed lock with a TTL: SET driver:42:lock <ride_id> NX EX 10. NX means "only if not already set" — atomic — and EX 10 self-expires the lock in 10 seconds, which doubles as the offer window. Driver accepts → lock released, ride → accepted. Driver ignores it → lock expires on its own, matcher re-offers to the next driver. No cron, no leaked locks.

At mid-level, the status-column answer passes; reaching the Redis TTL lock unprompted is upside.

Deep dive 3: The re-offer flow and staleness

When an offer times out, the matcher needs to wake up and try the next driver — a delay queue (a queue that delivers a message after a set delay) handles the timeout without polling. Also acknowledge staleness honestly: the "nearest driver" is wherever they were a few seconds ago. That's fine — matching is heuristic; the lock, not the location, is what guarantees correctness.

Wrap-up

Recap: fare and ride APIs feed a state machine; location ingestion lands in a Redis geo index that absorbs 1M writes/sec (with adaptive pings shrinking the number); matching queries nearby drivers and takes a TTL lock so no driver is double-offered. Consistency lives exactly where we promised — in dispatch — while locations stay approximate. With more time: surge pricing, ETA computation, and durable workflow engines for the offer flow.