System Design Case Study: A Ride-Hailing Service

October 18, 2026 · 11 min read

A ride-hailing app looks like a map with cars on it. Behind it, two hard problems meet: knowing where hundreds of thousands of moving drivers are, right now, and assigning each ride request to the right driver — exactly once — within seconds.

What makes this a good interview question is that the two problems want opposite things. The location stream wants raw write throughput and doesn't care about losing an update. The trip wants correctness and durability and doesn't care much about throughput. Most of the design is keeping those two apart.

1. Requirements

Functional

  • Riders request a ride from a pickup point to a destination, and see a price estimate first.
  • Nearby drivers get offers and accept or decline.
  • Both see each other's live position until pickup; the rider sees the car during the trip.
  • Trips progress to completion; fares are calculated and charged; both sides rate each other.

Non-functional

  • A match within a few seconds of the request.
  • Location updates visible within a second or two.
  • No driver assigned to two trips. No rider charged twice.
  • The trip record must never be lost — it's a financial record.
  • Location data can be lossy; trip data cannot.

Out of scope: driver onboarding and documents, scheduled rides, pooled rides (mention them as extensions), fraud detection.

2. The numbers

Say 500,000 drivers online at peak, each sending a location every 4 seconds, and roughly 1,000 ride requests a second at peak. The back-of-the-envelope post explains the method:

quantityestimatewhat it means
location writes500,000 ÷ 4 s = 125,000/sdominates everything; must be in memory
location payload~100 bytes → ~12 MB/s inboundbandwidth is fine; the write rate is what hurts
current-position state500,000 × ~100 B = ~50 MBfits in RAM on one node — but shard for throughput and blast radius
location history~1 TB/dayto a log and object storage, never the trip database
ride requests~1,000/s peakmodest; a durable relational DB is fine
trips/day~20–30 million~10 GB/day of trip rows

The single most useful thing these numbers say: the location service and the trip service are different systems. Draw them as separate boxes.

3. APIs

# driver app
PUT  /drivers/me/location     { lat, lng, heading, speed, ts }   (or over a socket)
PUT  /drivers/me/status       { status: "available" | "offline" }
POST /offers/{offerId}/accept
POST /offers/{offerId}/decline

# rider app
POST /quotes                  { pickup, dropoff, vehicleType } → { quoteId, fare, surge, expiresAt }
POST /trips                   { quoteId }   Idempotency-Key: ... → { tripId, status }
POST /trips/{id}/cancel
GET  /trips/{id}

# both, during a trip
WS   /trips/{id}/live         ← driver positions, status changes

Two details are worth saying out loud. Location updates usually ride on a long-lived connection rather than 125,000 separate HTTP requests a second — the connection is already open for offers anyway. And POST /trips takes an idempotency key, because a rider on a train tapping "Request" twice must not get two cars.

4. The flow

rider
dispatch
locations
driver
trips
location every ~4 s (lat, lng)
◀
location service
in-memory index by geo cell driver → cell, cell → drivers only the latest position is kept hot

Every online driver's app sends its position every few seconds. The location service keeps only the latest position, indexed by geo cell, in memory. History goes to cheaper storage.

0 / 6

5. The location service

125,000 updates a second, each replacing the last, needs a different store from the trip records:

  • Keep current positions in memory, indexed by geo cell — a geohash or H3 cell — for "drivers in these cells" queries. Redis with geo commands, or a dedicated in-memory service sharded by city or region.
  • Only the latest position matters for matching. Losing a few updates is harmless: the next arrives in 4 seconds. So this store can favour speed over durability.
  • History — for analytics, disputes and ETA models — goes to a log and cheap storage, asynchronously.

Shard by geography: a city's drivers and riders live on the same shard, and cross-shard queries are rare.

What an update does

async function onLocation(driverId, { lat, lng, ts }) {
  const cell = h3.latLngToCell(lat, lng, 9);        // ~0.1 km² hexagons
  const prev = await redis.hget(`driver:${driverId}`, 'cell');

  const tx = redis.multi()
    .hset(`driver:${driverId}`, { lat, lng, ts, cell })
    .expire(`driver:${driverId}`, 30);              // silent drivers vanish
  if (prev !== cell) {
    if (prev) tx.srem(`cell:${prev}`, driverId);
    tx.sadd(`cell:${cell}`, driverId);
  }
  await tx.exec();

  locationLog.publish({ driverId, lat, lng, ts }); // history, async
}

The cell index only changes when a driver crosses a cell boundary — a small fraction of updates — so most writes are a single hash overwrite. The TTL is the cheap failure detector: a driver whose phone dies stops refreshing the key and drops out of matching within 30 seconds, with no cleanup job.

Why hexagons

Square geohash cells have neighbours at two different distances (edges and corners), and cell sizes vary with latitude. H3's hexagons have six equidistant neighbours, so "this cell plus one ring around it" is a roughly circular search area. Either works in an interview; say which and why.

6. Matching

Finding candidates is a geo query over the rider's cell and its neighbours. Choosing among them is the interesting part:

  • Rank by ETA, not distance. Rivers, one-way streets and traffic make straight-line distance misleading. A routing service estimates arrival times over the road network.
  • Consider more than the nearest driver. Driver ratings, vehicle type, and — at scale — a global view: matching a batch of nearby requests at once can beat greedy one-by-one assignment.
async function findCandidates(pickup, vehicleType) {
  const origin = h3.latLngToCell(pickup.lat, pickup.lng, 9);
  for (const k of [1, 2, 3]) {                      // widen until enough
    const cells = h3.gridDisk(origin, k);
    const ids = await redis.sunion(cells.map((c) => `cell:${c}`));
    const drivers = (await loadDrivers(ids))
      .filter((d) => d.status === 'available' && d.vehicleType === vehicleType);
    if (drivers.length >= 5) return rankByEta(pickup, drivers);
  }
  return [];
}

rankByEta calls the routing service with one origin-to-many-destinations request, not one per driver. ETA is the expensive step, so cap the candidate list (say the 10 nearest by straight line) before asking for road times.

Greedy vs batched

Greedy matching assigns each request to its best driver the moment it arrives. It's simple and fast, but it's locally optimal: rider A takes the driver who was the only good option for rider B two seconds later. Batched matching collects requests for a second or two per area and solves them together as an assignment problem, minimising total pickup time. Large platforms batch; in an interview, start greedy and name batching as the improvement.

7. Exactly one trip per driver

Two dispatchers might offer the same driver to two riders at the same moment. The assignment must be atomic:

  • Mark the driver as reserved with a conditional update — "set reserved for trip X only if currently available" — in the store that owns driver state. The loser of the race moves to its next candidate.
  • Give the offer a timeout. If the driver doesn't respond in 15 seconds, the reservation is released and the next driver is tried.

In Redis, the conditional update is a small Lua script, which runs atomically:

-- KEYS[1] = driver:{id}   ARGV[1] = tripId   ARGV[2] = offer TTL (s)
if redis.call('HGET', KEYS[1], 'status') ~= 'available' then
  return 0
end
redis.call('HSET', KEYS[1], 'status', 'reserved', 'trip', ARGV[1])
redis.call('SET', KEYS[1] .. ':offer', ARGV[1], 'EX', ARGV[2])
return 1

This is the same "check and set in one step" as reserving a spot in the parking lot design, just distributed.

There's a subtlety: the reservation lives in the fast, lossy store, but the trip lives in the durable database. If the location shard restarts mid- offer, the reservation is gone. That's acceptable — the offer times out and matching retries — as long as the durable trip row is the source of truth for "this driver is on trip X", and the fast store is rebuilt from it. Never let the in-memory flag be the only record of an accepted trip.

8. Trips as a state machine

A trip moves through REQUESTED → ACCEPTED → DRIVER_ARRIVED → IN_PROGRESS → COMPLETED, or to CANCELLED from the early states. Model it explicitly (state machines), store it in a durable database, and validate every transition. Make the transition requests idempotent: a driver's phone retrying "start trip" on a bad connection mustn't start it twice.

-- every transition is a conditional update: it only succeeds
-- from the state the caller thinks the trip is in
UPDATE trips
SET status = 'IN_PROGRESS', started_at = now(), version = version + 1
WHERE id = $1 AND status = 'DRIVER_ARRIVED' AND driver_id = $2
RETURNING version;

Zero rows updated means the trip wasn't where the client thought — cancelled by the rider a moment ago, or already started by an earlier retry. The client re-reads the trip instead of guessing. This one pattern handles the race between "rider cancels" and "driver starts trip" that otherwise produces a trip that is somehow both.

Keep an append-only trip_events table alongside: every transition, with who triggered it and from which device. Support teams, fare disputes and fraud checks all start there.

9. Pricing and surge

The fare quote happens before matching, so the rider sees a price and commits to it:

  • Base fare from the route's estimated distance and time.
  • Surge multiplier per area, recomputed every minute or so from open requests versus available drivers in that area's cells. It's a streaming aggregation over the same request and location events, not a query at request time.
  • The quote is stored with an expiry (quoteId, a couple of minutes), and POST /trips references it. The price the rider accepted is the price you charge, adjusted only by rules they agreed to (a longer route they asked for, waiting time).

Storing the quote matters: if surge drops while the rider is deciding, recomputing at booking time would charge them a price they never saw.

10. Live tracking

During a trip, the rider sees the car move. The driver's location updates are fanned out to the rider over a WebSocket, via the same gateway pattern as a chat system. Clients smooth the movement between updates, so the car glides instead of jumping.

The fan-out is tiny — one driver, one rider (plus any shared-trip viewers) — but the connections are many: hundreds of thousands of open sockets across a fleet of gateway servers. The location service publishes trip:{id} updates to a pub/sub channel; whichever gateway holds the rider's socket is subscribed to it. Gateways stay stateless apart from their open connections, so a deploy just drops sockets and the apps reconnect.

When the rider's app is backgrounded or offline, don't queue every missed location. Only the latest position matters; send it on reconnect.

11. After the trip

Fare calculation (distance, time, surge multiplier), payment, receipts and ratings all happen after COMPLETED, asynchronously, through a queue. A slow payment provider must never block the next ride. If charging fails, retry with backoff, and run the overall flow as a saga with compensations where needed.

The COMPLETED transition and the "charge this trip" message must not diverge — a trip completed but never charged is lost revenue, and a charge for a trip that was rolled back is a support ticket. Write both in one transaction using the outbox pattern, and give the payment call an idempotency key derived from the trip ID.

12. What breaks first

  • A stadium empties. Thousands of requests from three cells in one minute. Batched matching and surge exist for exactly this; also shed load on non-essential calls (map tiles, promotions) before matching.
  • A location shard dies. Drivers in that city vanish from matching until their apps reconnect to a replacement and re-send positions — about one update interval. Keep a replica per shard if even that gap is too long.
  • The routing service is slow. Matching stalls on ETA calls. Use a circuit breaker and fall back to straight-line distance times a city-specific factor. Slightly worse matches beat no matches.
  • Flaky phone networks. Duplicate accepts, out-of-order location updates, stale ts. Drop location updates older than the one stored; make every trip transition conditional and idempotent.

13. Trade-offs to state out loud

decisionchoice herewhat you give up
current locationsin memory, TTL, lossydurability — rebuilt from the next update
geo indexH3 cells + neighbour ringsexactness at cell edges (fixed by ranking)
candidate rankingroad ETA on a capped listlatency of a routing call per request
assignmentatomic reserve + offer timeouta few seconds lost on each decline
matching strategygreedy first, batch laterglobal efficiency, until batching is added
paymentsasync saga after completionthe rider sees the charge a little later

14. Follow-up questions to expect

  • "Add pooled rides." Matching becomes a routing problem: insert a new rider into an existing trip only if every passenger's detour stays under a limit. The trip model needs multiple pickups and drop-offs.
  • "How do you compute ETAs fast?" Precomputed road-graph shortcuts (contraction hierarchies) plus live traffic speeds per road segment, served from memory. Not Dijkstra over the whole city per request.
  • "How do you stop GPS spoofing?" Plausibility checks — speed between updates, jumps across rivers — on the location stream, flagged asynchronously.
  • "Driver goes offline mid-trip." The trip is durable; it doesn't depend on the socket. The rider's view freezes on the last position, the driver's app resumes on reconnect, and support tooling can see the trip_events history.

Practise it

"Ride hailing" is one of the prompts in the system design practice tool. Draw the location service separately from the trip service — they have very different write patterns — and have Claude review the design.