System design practice

Ride Matching

medium geo caching write-heavy resilience

100k location updates per second into a geo store, not a database.

Solve it in your browser Read the lesson first

Design the dispatch core of a ride-hailing app. Drivers' phones report where they are every few seconds; when a rider asks for a ride, the system finds the nearest free driver, creates the trip and offers it to that driver.

Functional requirements

Use these use case and scenario names exactly: the traffic, requirements and tests in problem.proschi refer to them.

Scale

Constraints

What is given

problem.proschi declares the rider and the driver and holds the traffic, requirements and tests. Add the components, the connections and the two use cases.

How your design is checked

You write the design as text in Proschi. Tests run in your browser: a simulation of the traffic above checks latency, availability, cost and what happens when a machine fails. How the simulation works.

Start designing

Lesson · 12 min read

Learn it: Ride Matching #

Ride matching: a live map of 400,000 moving drivers #

What you'll learn #

The problem, explained #

This is the dispatch core of a ride-hailing app like Uber or Lyft. Two kinds of users talk to it:

The non-functional requirements:

The given file declares the two actors, the requirements, the tests and the traffic: 100k location updates a second and 2k ride requests a second, 5% of which find nobody. Everything else is yours.

The tests encode four ideas:

Back-of-the-envelope #

The location rate comes from fleet size and update interval: 400k drivers ÷ 4 s = 100k writes a second. Ride requests are 2k a second, 50 times fewer. That asymmetry is the whole problem.

FlowRateKind of operation
Location updates100k rpsoverwrite one key per driver
Geo searches (every ride request)2k rpsread: nearest within 3 km
Trips created (95% of requests)1.9k rpsdurable, strongly consistent insert
Offers to drivers1.9k rpsasync message

Could a relational database take the locations? The simulation's PostgreSQL takes 5k writes a second on its one primary, and read replicas do not add write capacity. 100k ÷ 5k = 20 shards at 100% utilisation, closer to 30 if you want headroom. Each shard is a full replica set: at least two replicas at $400 each. Even the bare minimum of 20 shards costs $16k a month, for data that is stale four seconds after it is written.

An in-memory geo store. Redis takes about 100k operations a second per node in the model. 100k updates + 2k searches ≈ 102k on the store. Two nodes run at about 50% each. But "survive any node failure" re-runs the analysis with one node fewer, and then one node sees 102k against 100k: saturated. You need a third node so the two survivors stay below 100%. That is the general rule: capacity after a failure = (n − 1) × per-node capacity must exceed the load.

The connection tier dominates the bill. Every update passes through whatever accepts driver connections. A service replica takes about 2k rps in the model, so 100k ÷ 2k = 50 replicas at 100%. At 70% you need about 72, and you want to stay below 70% with one lost, too. At $100 a replica, that is most of the $10k budget. This is why the hint says to keep these servers "just under 70% busy: enough headroom for the p99, no more". Past that point the model's queueing delay grows steeply, and below it every extra replica costs $100 for nothing.

Trips. 1.9k inserts a second against one PostgreSQL primary (5k) is under 40%. A single primary with a replica for failover is enough; no sharding needed for this part.

Latency. A location update is load balancer → gateway → one GEOADD. That is about ten milliseconds on average and a p99 in the tens, comfortably under 50 ms if the gateways are not overloaded. A matched ride is load balancer → matching service → GEOSEARCH → INSERT → enqueue, and the response. The async enqueue counts its own hop, but not what the consumer does later.

Concepts #

Geospatial indexing #

A database index on lat and lng separately does not answer "drivers within 3 km": a B-tree can range-scan one dimension, not a circle. Geospatial indexes map two-dimensional space onto something an ordinary index can handle.

Trade-offs: geohash and S2 are simple and fit any key-value store. Quadtrees adapt to density but need rebalancing as drivers move. H3 is best for analytics and pricing over areas. For an interview, name one, explain cells plus neighbours, and move on.

Ephemeral state versus the record #

Positions and trips look like "driver data", but they have opposite needs:

Driver positionTrip
Write rate100k/s1.9k/s
Value of old datanone after a few secondsbilled and audited for years
On lossthe next update in 4 s fixes itmoney and trust lost
Consistencylatest winstwo riders must not get one driver

So they get different stores. Positions go to an in-memory, overwrite-in-place store (a key per driver, a TTL so offline drivers disappear). Trips go to a durable, strongly consistent database, written before the rider hears back. A unique constraint on "the active trip of driver d7" makes the trip itself the lock: when two matching requests pick the same driver, one insert wins and the other fails cleanly and tries the next candidate.

When not to split: if positions had to be kept for compliance or for route replay, you would also stream them to cheap storage asynchronously, but you still would not make the matching path wait on that.

title "Hot state in memory, records in a database"
app    "App"        [REST API]   x2
live   "Live state" [Redis]      x3 "Latest value per key, overwritten"
record "Records"    [PostgreSQL] x2 "Durable, strongly consistent"
app -> live   : SET
app -> record : SQL

Asynchronous hand-off with a queue #

The offer must reach the driver's phone, which is connected to some gateway node. If the matching service called the gateway synchronously, the rider would wait for the push, and the request would fail whenever that gateway is busy. Instead, the matching service publishes an event (RideOffered d7) to a queue and answers the rider. A consumer then delivers the offer to the driver's connection.

In Proschi, ->> is an asynchronous send: the hop counts once for the sender, but nothing after it delays the response.

title "Fire and forget"
user   "User"   [Actor]
api    "API"    [REST API] x2
events "Events" [Kafka]    x2
worker "Worker" [Service]  x2
user   -> api
api    -> events : produce
events -> worker : consume

usecase "Do something" {
  user    -> api    : POST /things
  api    ->> events : ThingHappened
  events ->> worker : ThingHappened
  api    --> user   : 202
}

When not to use it: when the caller needs the result. Here the rider needs the trip id (from the database, synchronous), not confirmation that the push was delivered.

Designing it step by step #

1. Scope. Clarify that you are designing matching, not pricing, ETAs, payments or the trip lifecycle. Confirm the update interval (4 s), the fleet size (400k online at peak), the search radius (3 km) and the requirement that a confirmed trip never disappears. Derive the 100k writes a second out loud; it is the number that shapes everything.

2. High level. Drivers keep a long-lived connection (a websocket) to a gateway tier behind a load balancer. The gateway takes location updates and pushes offers. Riders call a matching service over HTTPS. Three stores: the live map (positions), the trips database, and a queue for offers. Draw both use cases as sequences.

3. Deep dive.

Where positions live. Start from the naive design (UPDATE drivers SET lat, lng) and count: it needs dozens of database shards. Then propose an in-memory geo index. Explain GEOADD (overwrite one member, about a millisecond) and GEOSEARCH (radius, nearest first). Mention partitioning the map by city or by geo cell when one node's memory or throughput runs out; in this exercise the model spreads load evenly over replicas, so you only need enough of them.

Survive a failure. Show the n − 1 arithmetic for the live map. Mention that a lost node loses at most a few seconds of positions, which the next round of updates restores; that is why an in-memory store is acceptable here and not for trips.

Matching and the lock. The matching service searches, picks the nearest candidates, and inserts the trip with a uniqueness rule on the driver's active trip. If the insert fails because another rider just got that driver, try the next candidate. Write before responding, in a strongly consistent store.

The offer. Publish to the queue with an async send; the gateway that holds the driver's connection consumes and pushes.

Sizing. The gateways carry 100k updates a second and cost the most; size them to stay under about 70% busy with one replica lost, and no further. Everything else is small.

4. Wrap up. Mention what you skipped: drivers declining offers (timeouts, re-offer to the next driver), surge pricing by cell, batching riders and drivers for globally better matches instead of greedy nearest-first, and multi-region (cities are natural partitions).

Common mistakes #

Locations in the database (wrong/locations-in-database). The starter does this, and it feels natural: drivers are rows, so update the row. 100k writes a second land on a PostgreSQL primary built for 5k, which saturates and drags down both use cases that touch it. Caught by "Location updates are writes to the live map, never the database" and p99 of Update location < 50 ms.

Locations in a heavily sharded database (wrong/locations-in-sharded-database). The fix for the previous one if you only look at utilisation: 22 shards. The writes now fit, but at 93% busy the database still breaks both p99 limits, and the design costs about $26k a month, more than 2.5 times the budget, for data nobody needs to keep. Caught by the same flow test and cost ≤ $10,000/month.

The ride request waits for the offer queue (wrong/request-waits-for-offer-queue). A synchronous publish with an ack. It adds latency and couples the rider's success to the queue and, in real systems, often to the downstream push. Caught by "The driver's offer never holds up the rider".

Trips in an eventually consistent NoSQL table (wrong/trips-in-nosql). Scales nicely, but a lagging read can show a driver as free right after another rider got them, and two trips for one driver follow. Caught by "Trips are stored strongly before the rider hears back".

Classic mistakes beyond the tests:

In the interview #

Start with the asymmetry: "100k tiny writes a second where only the latest value matters, against 2k searches and 1.9k precious inserts." Then say that the design separates the two kinds of state, and justify each store by its numbers.

Likely follow-ups:

Further reading #

Now design it

More system design problems