Build a ride-hailing platform: a rider requests a ride, the system finds a nearby driver within seconds, exactly one driver gets assigned, and both sides watch the trip unfold live on the map.
Matching (also called dispatch) is pairing one ride request with one driver. The consistency requirement comes from the physical world: a driver is a physical resource, like a bookable time slot in Design Calendly. If two riders are ever assigned the same driver, one of them is standing on a curb waiting for a car that is driving away in another direction - the failure is visible, personal, and immediate. That makes assignment the one place in this system that must be transactionally correct, no matter how many matching servers are working the same busy neighborhood at once. Meanwhile almost everything else here - positions on the map, ETAs, surge levels - is allowed to be a few seconds stale, because the physical world moves slowly enough that "roughly right, right now" beats "exactly right, too late." Splitting the system along that line is most of the design.
The map is a cache of the physical world. Every driver position we hold is already slightly wrong the moment it arrives - the car kept moving. A position that is 4 seconds old is normal and fine: matching ranks candidates by ETA, and a few seconds of drift changes nothing about who is closest. So the whole location pipeline is built for freshness and volume, not durability or precision: fast in, fast out, gone in seconds. Assignment is the opposite. "Which driver is on which ride" is not a reflection of the physical world - it IS the system's decision, and both people rearrange their next twenty minutes around it. There is no acceptable staleness for a booking: it is either true or it is a double-booking. So assignment lives in a transactional database with a constraint that makes the bad state impossible to write. This is the same per-operation consistency argument as Design Tinder (strong for swipes, eventual for the feed), pushed further by physical stakes: eventual consistency for where things are, strong consistency for who is committed to whom.
The numbers split the system into two workloads with opposite needs, and the design falls out of respecting the split. The location firehose (400K writes/sec) is ephemeral by nature: only the latest position per driver matters, every write overwrites the previous one, and a position not refreshed for 30 seconds should disappear anyway (the driver's app died or they went offline). That profile - latest-value-only, huge rate, self-expiring - belongs in memory, not in a database: a Redis geospatial index per city, where a ping is one in-place update and stale entries evict themselves via TTL. Writing 17B pings/day into PostgreSQL would buy durability for data that is meaningless within seconds - all cost, no benefit. This is the third appearance of the ephemeral-data rule in this set: cursors in Design Excalidraw and typing indicators in Design WhatsApp made the same argument at smaller scale. Rides are the opposite: a few thousand writes/sec of genuinely precious, transactional data - who is assigned to whom, what state the trip is in, what was charged. Small enough for PostgreSQL without ceremony, and PostgreSQL is exactly what the assignment invariant wants: a partial unique index that makes "one active ride per driver" a rule the database enforces atomically. One more thing falls out of the estimates: geography is the natural shard key. A proximity search in Tokyo never touches São Paulo data - rides are intrinsically local. So the geo index shards cleanly by city (each city's live map fits on a node), and the platform regionalizes with almost no cross-shard traffic. Few systems hand you sharding this kind; it is worth pointing out in an interview that ride-hailing is one of them.
LOCATION FIREHOSE - always on while a driver is online
[Driver app] --ping every ~5s--> [Location Service]
|
+--> [Geo Index (Redis GEO, per city)]
| latest position only, TTL ~30s
+--if on active ride--> [Tracking GW]
==push==> rider
REQUEST -> MATCH -> ASSIGN
[Rider app] --> [API Gateway] --> [Fare Service] --quote--> rider confirms
|
v
[Matching Service]
|
[Geo Index] "available drivers near pickup"
| ranked by ETA
v
lock candidate #1 (Redis SET NX, 15s TTL)
--offer--> driver's app
| accept | decline / timeout
v v
[Ride Service (PostgreSQL)] release lock,
transaction: offer candidate #2
driver still available?
write assignment
UNIQUE "one active ride
per driver" = final referee
|
v
rider + driver both notified; trip runs through the
state machine: matched -> arriving -> in_progress -> completedA geohash encodes a latitude/longitude pair into a short string - say "9q8yy" - with a lovely property: points that are near each other on the map share a prefix. The string works like a grid address: each added character subdivides the cell into smaller cells, so a prefix length picks a cell size. "Find drivers near me" stops being math over coordinates and becomes "look in my cell and its 8 neighbors" - a handful of key lookups. (The neighbors matter because you might be standing at a cell edge; searching only your own cell would miss a driver 50 meters away across the boundary.) Redis GEO commands (GEOADD, GEOSEARCH) are built on exactly this idea, packing geohash-style scores into a sorted set. Contrast this with Design Tinder, which put locations in PostgreSQL with PostGIS. Same kind of query, opposite data: profiles are durable records updated occasionally, so a database index fits. Driver positions update every 5 seconds and expire in 30 - putting them behind a database's durability machinery is paying for exactly what the data does not need. Sharding by city works because proximity queries are intrinsically local: no search in Tokyo ever needs São Paulo's drivers. Each city's live map fits comfortably on one node (2M drivers globally is a few hundred MB), hot megacities can split further by geohash prefix, and the failure blast radius of any one node is one city, not the platform.
Do the math the estimates set up: 17B pings/day into PostgreSQL would be one of the largest write workloads imaginable, buying durability for data that is stale in 5 seconds and worthless in 30. Durability is for data you will want later; there is no "later" for a driver position. The Redis GEO index matches the data's real semantics instead. Latest-value-only: a ping is an overwrite (GEOADD on the same driver_id replaces the old position), so the index never grows with traffic - it holds one entry per online driver, a few hundred MB globally. Self-expiring: each entry rides a TTL refreshed by every ping, so a driver whose app crashes simply vanishes from the map within 30 seconds. That eviction is not a cleanup chore - it IS the correct behavior, automatically: unreachable drivers must not be offered rides. The failure mode and the feature are the same mechanism. What about history? The one legitimate consumer is the completed trip - receipts, fare disputes, safety reviews - and it needs a route, not a firehose: at trip end we write a downsampled polyline to object storage and pin its pointer to the ride row. Everything else evaporates, which is also the right privacy posture: the system cannot leak movement history it never stored. Rule of thumb, third time in this set (Excalidraw cursors, WhatsApp typing indicators, now this): match the store to the data's lifespan. Seconds-lived data gets memory; years-lived data gets a database.
Database Schemas
- Drivers (PostgreSQL)
driver_id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) UNIQUE NOT NULL,
vehicle JSONB, -- make, model, plate, type
status driver_status_enum DEFAULT 'offline', -- offline|available|on_ride
rating DECIMAL(3,2),
city_id VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_driver_city_status ON (city_id, status)
- Rides (PostgreSQL) -- the precious table: small, transactional, constrained
ride_id UUID PRIMARY KEY,
rider_id UUID NOT NULL REFERENCES riders(rider_id),
driver_id UUID REFERENCES drivers(driver_id), -- NULL until matched
quote_id UUID NOT NULL,
pickup_point GEOGRAPHY(POINT) NOT NULL,
pickup_address TEXT,
dropoff_point GEOGRAPHY(POINT) NOT NULL,
dropoff_address TEXT,
state ride_state_enum DEFAULT 'requested',
-- requested|matched|arriving|in_progress|completed|cancelled
surge_multiplier DECIMAL(3,2) DEFAULT 1.0,
quoted_fare DECIMAL(8,2),
final_fare DECIMAL(8,2),
route_ref VARCHAR(512), -- object-storage key, written at completion
requested_at TIMESTAMP DEFAULT NOW(),
matched_at TIMESTAMP,
pickup_at TIMESTAMP,
completed_at TIMESTAMP,
cancelled_at TIMESTAMP,
cancel_reason TEXT,
-- THE REFEREE: double-booking is unwritable, no matter how matchers race
UNIQUE (driver_id) WHERE state IN ('matched','arriving','in_progress')
UNIQUE (rider_id) WHERE state IN ('requested','matched','arriving','in_progress')
INDEX idx_rider_history ON (rider_id, requested_at DESC)
INDEX idx_driver_history ON (driver_id, requested_at DESC)
Redis keys (per city where noted)
- Geo Index -- the live map; deliberately not durable
GEOADD "geo:{city}:available" lon lat driver_id (ping = overwrite)
Companion TTL key "loc:{driver_id}" EX 30 (no ping -> evicted)
Query: GEOSEARCH near pickup, radius ~2-5km
- Offer Lock -- one offer per driver at a time
SET "offer:{driver_id}" ride_id NX EX 15
-- NX: only the first matcher wins; EX 15: ignored offers self-release
- Fare Quotes
"quote:{quote_id}" -> { fare, surge, route_summary } TTL 5 min
- Surge multipliers (external input, read-only to us)
"surge:{city}:{geohash_cell}" -> 1.4{
"pickup": { "lat": 37.7749, "lng": -122.4194 },
"dropoff": { "lat": 37.7849, "lng": -122.4094 },
"vehicle_type": "standard"
}{
"quote_id": "q_7a2b8f4e",
"fare": 12.75,
"currency": "USD",
"surge_multiplier": 1.2,
"eta_to_pickup_minutes": 4,
"trip_minutes": 15,
"expires_at": "2026-07-05T18:09:11Z"
}
// The quote is persisted until expires_at: the price shown is the price
// charged, even if surge changes a moment after the rider saw it.{
"quote_id": "q_7a2b8f4e"
}{
"ride_id": "r_4c8e6f2a",
"state": "requested",
"matching": true,
"track_url": "wss://api.example.com/v1/rides/r_4c8e6f2a/track"
}
// Matching runs asynchronously - the rider connects to track_url and
// receives the match the moment a driver accepts. An Idempotency-Key
// header makes a retried request return this same ride, never a second one.// pushed to the driver's app over their connection:
{
"offer_id": "o_9d4f1a2b",
"ride_id": "r_4c8e6f2a",
"pickup_address": "123 Market St",
"eta_to_pickup_minutes": 4,
"estimated_fare": 12.75,
"expires_in_seconds": 15
}// driver taps accept -> POST /v1/offers/o_9d4f1a2b/accept
{
"ride_id": "r_4c8e6f2a",
"state": "matched",
"rider": { "name": "Jamie", "rating": 4.9 },
"pickup": { "lat": 37.7749, "lng": -122.4194,
"address": "123 Market St" }
}
// If the 15s lock expired, or the assignment transaction lost a race,
// accept returns 409 offer_no_longer_available and the driver is
// immediately eligible for the next offer. Declining or ignoring the
// offer releases the lock the same way.{
"lat": 37.7799,
"lng": -122.4144,
"heading": 45,
"speed_kmh": 38,
"ts": "2026-07-05T18:05:20Z"
}{
"ok": true
}
// The busiest endpoint in the system (~400K req/sec at peak) and the
// cheapest: validate, overwrite one in-memory geo entry, refresh a TTL,
// maybe forward to one tracking channel. It never touches a database.{
"type": "driver_location",
"ride_id": "r_4c8e6f2a",
"state": "arriving",
"driver": {
"name": "Alex",
"vehicle": { "make": "Toyota", "model": "Camry",
"plate": "ABC123", "color": "Silver" },
"position": { "lat": 37.7774, "lng": -122.4169, "heading": 210 }
},
"eta_minutes": 3
}
// Pushed every few seconds; the app animates between updates so the car
// glides instead of hopping. State transitions (arriving, in_progress,
// completed) arrive on this same channel. Only the ride's two
// participants are authorized to subscribe.Geohash cell: one grid square of the map, addressed by a string prefix - longer prefix, smaller square. Proximity search means "my cell plus its 8 neighbors," and the neighbors are not optional: a rider standing at a cell edge has drivers 50 meters away in the adjacent square, and searching only one cell would miss them. This boundary case is the classic follow-up question, so know it. Partial unique index: a uniqueness rule that applies only to rows matching a condition - here, UNIQUE(driver_id) WHERE state is active. It allows unlimited completed rides per driver (history) while making a second concurrent one physically unwritable. It is the same "referee inside the database" as Design Calendly's exclusion constraint and Design Tinder's match constraint, specialized with a WHERE clause: enforce the invariant exactly where it applies and nowhere else. Interpolation: the client-side animation between position updates. The server sends a position every few seconds; the app moves the car smoothly along the line between them. Users perceive a live continuous feed, but the smoothness is manufactured on the phone - which is what frees the backend to run at a battery- and bandwidth-friendly update rate. A good reminder that perceived real-time and actual update rate are separate design dials.