system-design

Design a Ride-Hailing Service (Uber / Careem / Grab)

Difficulty: Tier 3 Asked at: Uber, Careem, Grab, Amazon; the flagship question for MENA/SEA scale-ups Time budget: 45–60 min

This is the marquee question for Careem, Grab, and Uber — and it’s genuinely different from the feed/chat family because the data is geospatial and moving in real time. The core challenges are “find nearby drivers” over a constantly-updating map (geospatial indexing) and matching a rider to a driver. Nail geospatial indexing and the matching flow and you’ve got the spine of it.

Prerequisites: Geospatial Indexing, WebSockets, Message Queues


1. Requirements

Functional:

Non-functional:

Out of scope: payments internals (separate), surge-pricing ML, maps/routing internals (separate).


2. Estimation


3. The core problem: find nearby drivers

🚨 You cannot scan all drivers and compute distance — millions of them, thousands of queries/sec. You need a geospatial index that buckets drivers by location so “nearby” is a cheap lookup. (Geospatial Indexing)

Two standard approaches:

Store drivers in the index keyed by cell; a query fetches candidates from the rider’s cell and adjacent cells, then ranks by actual distance/ETA.


4. High-level design

flowchart TB
    Driver -->|location every ~4s| LocSvc[Location Service]
    LocSvc --> GeoIdx[(Geospatial index<br/>Redis: geohash/cells)]
    Rider -->|request ride| MatchSvc[Matching Service]
    MatchSvc --> GeoIdx
    MatchSvc -->|nearby drivers| Dispatch[Dispatch]
    Dispatch -->|offer| Driver
    Driver -->|accept| TripSvc[Trip Service]
    TripSvc --> TripDB[(Trip state store)]
    Driver <-->|live location| WS[WebSocket / streaming] <--> Rider

5. Deep dives

5a. Ingesting 250K location updates/sec

Don’t write each ping to a durable DB — too much write load, and you only need the latest position. Update an in-memory geospatial store (Redis with geo commands, or a sharded in-memory service) keyed by driver, partitioned by region so a city’s load spreads across shards. Old positions are simply overwritten. Optionally sample/aggregate pings; you don’t need every one persisted. 🚨 Latest-location-wins + in-memory = this becomes tractable.

5b. The matching flow

  1. Rider requests → matching service computes the rider’s cell.
  2. Query the index for available drivers in that cell + neighbors.
  3. Rank candidates by ETA (needs the road network, not straight-line distance — reference maps), rating, driver preferences.
  4. Offer to the best driver; if they decline/time out, offer the next. 🚨 This is a sequential/ short-list dispatch, not first-come chaos — avoid offering one ride to 50 drivers at once (double-booking) or racing.
  5. On accept, atomically mark driver busy and rider matched (a transactional state change to prevent double-assignment).

5c. Preventing double-booking

A driver must not be offered two rides simultaneously, and a ride must go to one driver. Use an atomic claim: when a driver accepts, compare-and-set their state busy; if already busy, the accept fails and the rider is re-matched. The matching service serializes offers per driver. (Distributed Locking)

5d. Live tracking

During a trip, driver and rider exchange live locations over a persistent connection (WebSocket) so the rider sees the car move and ETA updates. This is the chat connection pattern applied to location streams.

5e. Regional sharding & the density problem

Partition everything by geography (city/region) — a driver in Karachi never matches a rider in Dubai, so shards are naturally independent and scale per-city. Dense areas (airport, downtown) are hotspots — quadtree/ S2 adaptively subdivides dense cells so a single cell doesn’t hold too many drivers. (Hot Keys)

5f. Trip state machine & reliability

A trip moves through states (requested → accepted → arrived → started → completed). Persist state transitions durably (they drive payment and support). Handle failures: driver app crashes mid-trip, network drops — the state machine must recover to a consistent state, not lose the trip.


6. Bottlenecks & scaling further

  1. Location write load → in-memory geo index, latest-wins, regional sharding.
  2. Nearby queries → geohash/S2 index makes them O(cell), not O(all drivers).
  3. Dense-city hotspots → adaptive cells (quadtree/S2), regional shards.
  4. Matching races → atomic driver-claim, serialized offers.
  5. Live tracking connections → WebSocket connection servers (chat pattern).

7. Trade-off summary

Decision Chosen Alternative Why
Nearby search Geospatial index (geohash/S2) Scan all drivers O(cell) vs O(millions) per query
Location storage In-memory, latest-wins Durable per-ping 250K writes/sec; only latest matters
Partitioning By geography By user hash Rides are inherently local; independent shards
Matching Sequential offer + atomic claim Broadcast to all Prevents double-booking
Live tracking WebSocket streaming Polling Real-time car movement

8. Follow-up questions

How do you find nearby drivers without scanning them all? Use a geospatial index that buckets drivers by location so proximity becomes a lookup instead of a scan. With geohashing, you encode each driver's latitude/longitude into a string whose shared prefix length corresponds to spatial closeness, so all drivers in a small area share a prefix; a query hashes the rider's location and reads the matching cell plus its neighboring cells to get candidate drivers, then ranks those few by actual ETA. Quadtrees or Google's S2/H3 cells do the same with adaptive subdivision that handles dense areas better. Either way, "drivers near here" touches only the handful of cells around the rider — a cheap operation — rather than computing distance to every driver in the system, which would be impossible at millions of drivers and thousands of queries per second.
250,000 location updates per second — how do you not melt the database? Don't treat location like durable transactional data. You only ever need each driver's *latest* position, so you keep positions in an in-memory store (Redis geo, or a sharded in-memory location service) where a new ping simply overwrites the old one — no history, no disk write per ping. Partition the store by geography so each city/region's updates land on different shards, spreading the 250K/sec across many nodes. You can also sample or slightly batch pings, since sub-second precision on a car's position isn't required. The combination — latest-wins semantics, in-memory storage, and regional sharding — turns a crushing write load into something each shard handles comfortably, while durable stores are reserved for trip state that actually matters.
How do you prevent two riders from being matched to the same driver? Serialize offers per driver and make the acceptance an atomic claim. The matching service doesn't broadcast one ride to many drivers hoping one accepts; it offers to the best candidate, and only if they decline or time out does it move to the next — so a driver isn't juggling simultaneous offers. When a driver accepts, the system does an atomic compare-and-set on that driver's state from "available" to "busy" (a conditional update or distributed lock); if the driver is already busy, the accept fails and that rider is re-matched to someone else. This guarantees each driver is assigned to exactly one trip and each ride to exactly one driver, even under concurrent requests in a dense area.
Why partition by geography rather than by user or driver ID? Because rides are inherently local: a rider is only ever matched with drivers physically near them, so all the data and computation for a request is confined to one geographic area. Partitioning by region means a matching query only ever touches the shard(s) for that area, shards are independent (Karachi's load never touches Dubai's), and you can scale hot cities independently by giving them more capacity. Partitioning by user or driver hash would scatter geographically-adjacent drivers across all shards, so every "nearby" query would have to fan out to every shard — the opposite of what you want. Geographic partitioning aligns the data layout with the access pattern, which is the whole art of sharding.

9. What junior / mid / senior answers look like


Further reading