Design a Ride-Sharing Service
Uber System Design
Overview
A ride-sharing service connects a rider who wants a ride with a nearby driver who can give it — like Uber, Lyft, or Grab. A rider opens the app, requests a ride from their current location, and within seconds the system finds a free driver close by, assigns one, and then tracks the trip on a live map until it ends. That experience hides two hard engineering problems.
First, finding nearby drivers fast. "Which available drivers are within 2 km of this rider, right now?" sounds simple, but a plain database scan over millions of moving drivers is far too slow. You need a geospatial index built for "find things near a point."
Second, absorbing a firehose of location updates. Every active driver reports their position every few seconds. With hundreds of thousands of drivers online, that is tens of thousands of writes per second (~50K/sec) — a very write-heavy workload that would crush an ordinary database if you tried to durably store every ping.
It is "Hard" because you must combine low-latency proximity search, an enormous write throughput for location pings, and correctness on the match — a driver must be assigned to exactly one rider, never two — all while staying highly available.
In this walkthrough you'll scope it, size it, model the data, design the API, draw the location/matching/trip paths, and go deep on geospatial search, the location write firehose, and matching consistency.
Functional requirements
- Request a ride. A rider asks for a ride from their pickup location (and a destination).
- Match a nearby driver. The system finds available drivers close to the rider and assigns exactly one.
- Real-time driver location and ETA. While matched and en route, the rider sees the driver moving on a map and an estimated time of arrival.
- Trip lifecycle. A trip moves through clear states:
requested → matched → en route (to pickup) → in progress → completed(withcancelledas a possible exit).
Out of scope (state it): pricing and surge internals, payments depth (we assume a payment step exists but don't design the ledger), and ratings/reviews. Naming these keeps you from spending interview time on side problems — the core is matching, location, and the trip.
Non-functional requirements
- Low-latency nearby search. Finding candidate drivers must take milliseconds, not seconds — the rider is staring at a spinner. Target the proximity query well under ~100 ms p99.
- Very high write throughput for location pings. Hundreds of thousands of drivers each report their position every few seconds — tens of thousands of writes per second (~50K/sec). The location path must absorb this firehose cheaply.
- High availability. The service must keep matching riders even if some components fail — a rider stuck unable to get a ride is a lost trip and a lost customer.
- Consistency on the match. A driver must be assigned to exactly one rider at a time. Two riders grabbing the same driver is a correctness bug, so the assignment step needs a strong guarantee even though the rest of the system can be looser.
Tip
Separate the loose path from the strict path early. Location pings can be approximate and lossy; the match must be exact. Saying this upfront frames the whole design.
Estimations
State assumptions; the goal is to justify the write-optimized location path and the in-memory geospatial index — not to be exact.
Rounded assumptions
- Active (online) drivers at peak: ~200K.
- Each driver sends a location ping every ~4 seconds.
- Location pings/sec ≈ 200K ÷ 4 = ~50K pings/sec sustained, the dominant write load.
- Ride requests at peak: ~5K requests/sec platform-wide — far smaller than the ping load.
- Each match queries the geospatial index for drivers within a radius (a read), so matching reads are also roughly thousands/sec.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| ~50K location writes/sec, ever-changing | Keep current location in an in-memory store with last-write-wins; don't durably persist every ping. |
| Pings are tiny but constant | Use a write-optimized ingestion path separate from the durable trip store. |
| Matching needs "drivers near point X" fast | Use a geospatial index (in-memory), not a scan over a SQL table. |
| One city is a hotspot; the world is huge | Shard the location store and index by geographic region. |
| Ride requests (~5K/sec) ≪ pings (~50K/sec) | The match path can afford a stronger consistency check; the ping path cannot. |
Storage
- A driver's current location is tiny: driver id (8 bytes), latitude + longitude (16 bytes), timestamp (8 bytes), and small indexing metadata ≈ ~40 bytes. For 200K drivers that's ~8 MB of hot location state — it fits comfortably in memory. The point is not storage size; it's the update rate.
- Trips are durable and grow forever: trip id, rider, driver, status, timestamps, route summary ≈ a few hundred bytes each. Tens of millions of trips/day is large but is an append-mostly, write-once-read-occasionally workload — a good fit for a partitioned durable store.
Caution
Do not try to durably store every location ping. At ~50K writes/sec of data you immediately overwrite, you'd be paying for disk durability you don't need. Only the current location matters for matching, and the trip's route can be sampled separately.
Core entities / data model
| Entity | Key fields |
|---|---|
Driver |
driver_id (PK), availability (OFFLINE/AVAILABLE/ASSIGNED/ON_TRIP), last_location (lat,lng), geo_cell, last_seen |
Rider |
rider_id (PK), name, current location, payment ref |
Trip |
trip_id (PK), rider_id, driver_id, status, pickup, destination, timestamps |
| Location store | driver_id → (lat, lng, updated_at) — the hot, in-memory, last-write-wins map |
Two stores with very different jobs:
- The location store is the hot write path: an in-memory key-value map (and a geospatial index over it) keyed by
driver_id, updated ~50K times/sec, last-write-wins, disposable. It stores the driver's latest coordinates and freshness, not the authoritative assignment state. Losing it on a restart costs nothing permanent — drivers re-ping within seconds and it refills itself. - The durable Driver store holds the authoritative
Driverrow, including theavailabilityfield; the Trip store holds the trip lifecycle. Both live in the same strongly consistent, partitioned relational store family (Postgres/MySQL, partitioned by region or trip/driver id). This is where money and accountability live, so it must not lose writes.
availability is a column on the durable Driver row — it lives in the durable Driver store, never in the in-memory location store, which holds only coordinates and freshness. It is the strict state the match path controls: the match path flips AVAILABLE → ASSIGNED atomically (deep dive 3), and trip lifecycle updates move it to ON_TRIP or back to AVAILABLE. Location pings must not be allowed to overwrite this field, or a stale ping could accidentally make an assigned driver look available again. The geo_cell is the index bucket the driver currently falls in (deep dive 1).
API design
Three groups of calls: the rider requesting and tracking a ride, the driver streaming location, and the realtime channel that pushes the driver's position back to the rider. We label the request payload Body: and the response Returns:.
Request a ride
POST /api/rides
Body: { "rider_id": "...", "pickup": {"lat": ..., "lng": ...}, "destination": {"lat": ..., "lng": ...} }
201 Created
Returns: { "trip_id": "...", "status": "requested" } (then matched asynchronously)
The rider asks for a ride from their pickup point. The server creates a trip in requested state and kicks off matching; the actual driver assignment arrives moments later over the realtime channel, so this call returns immediately rather than blocking until a driver is found.
Driver location update (high frequency)
POST /api/drivers/{id}/location
Body: { "lat": ..., "lng": ... }
200 OK
Returns: { "ok": true }
Each driver's app sends this every ~4 seconds. It is the firehose — tiny, frequent, and best-effort. The server overwrites the driver's current location (last-write-wins) and updates the geospatial index. It does not let the client set availability on every ping; availability changes go through explicit online/offline, accept, decline, and trip-state transitions. A dropped ping is harmless; the next one arrives in seconds. In practice this is often a lightweight persistent connection (e.g. a WebSocket or gRPC stream) rather than a fresh HTTP request each time, to avoid per-ping connection overhead.
Get trip status
GET /api/rides/{trip_id}
200 OK
Returns: { "trip_id": "...", "status": "en_route", "driver": {"lat": ..., "lng": ..., "eta_seconds": 240} }
A pull endpoint for the current trip state and the matched driver's latest position and ETA — a fallback for when the realtime channel reconnects.
Realtime updates to the rider (push channel)
Channel: WebSocket /ws/rides/{trip_id}
Server → client messages:
{ "type": "matched", "driver": {...}, "eta_seconds": 300 }
{ "type": "location", "lat": ..., "lng": ..., "eta_seconds": 240 }
{ "type": "status", "status": "in_progress" }
Rather than have the rider poll every second, the server pushes updates over a persistent connection. A WebSocket carries the match result, the driver's moving dot, the live ETA, and status changes. This is what makes the map feel live.
High-level architecture
Three flows matter and they have very different shapes: a location ingestion path (the write firehose), a matching path (proximity search → assign), and a trip path (durable lifecycle + realtime updates to the rider). We'll walk each, then combine them.
1. Location ingestion path
Drivers stream their positions; the system must absorb the firehose and keep the geospatial index current — cheaply, in memory.
flowchart LR
Driver[Driver app] -->|"ping every ~4s"| Ingest[Location Ingestion Service]
Ingest -->|"overwrite current loc (last-write-wins)"| Loc[(Location Store - in-memory, sharded by region)]
Ingest -->|"update bucket"| Geo[(Geospatial Index - in-memory)]
- Each driver app sends a small location ping every ~4 seconds.
- The Location Ingestion Service writes the driver's current position into an in-memory location store, overwriting the previous value (last-write-wins — old positions are worthless).
- It also updates the geospatial index so the driver now sits in the right bucket for "find drivers near X" queries. Each ping recomputes the driver's cell id from the new coordinates; if it differs from the stored
geo_cell, the service deletes the driver from the old bucket and inserts them into the new one (one delete + one insert), then updatesgeo_cell. Most pings stay in the same cell, so the common case is a cheap no-op — just the coordinate overwrite. The index is a derived view of the location store, not an independent source of cell membership:geo_cellis recomputed from the stored coordinates on every ping, so the two cannot silently diverge. Both stores are sharded by geographic region so one city's load stays on its own servers.
Warning
Do not route every ping through a durable database. The ping path must be the cheapest possible write — in memory, no disk durability per ping — or it becomes the bottleneck that takes the whole service down.
2. Matching path
A ride request triggers a proximity search, picks a driver, and atomically assigns them so no two riders get the same driver.
flowchart LR
Rider[Rider app] -->|"request ride"| Match[Matching Service]
Match -->|"drivers within radius R"| Geo[(Geospatial Index)]
Geo -->|"candidate drivers"| Match
Match -->|"atomically assign one (CAS)"| DriverDB[(Driver store - durable)]
Match -->|"create trip = matched"| Trip[Trip Service]
- A ride request arrives at the Matching Service with the rider's pickup location.
- It queries the geospatial index for nearby drivers within a radius R (e.g. 2 km), then filters those candidates by availability against the authoritative
Driverstore (the index holds only coordinates, not availability), widening the radius if none are found. - From the candidates it picks one (closest ETA is the common choice) and tries to atomically flip that driver's availability
AVAILABLE → ASSIGNEDin the durable Driver store — a conditional update so two simultaneous requests can't both claim the same driver (deep dive 3). - On success it creates a trip in
matchedstate via the Trip Service; if the assignment lost the race, it picks the next candidate and retries. The flip toASSIGNEDonly offers the ride — the trip is confirmed once the driver accepts, and is released back toAVAILABLEon decline or timeout (deep dive 3).
3. Trip path
Once matched, the trip moves through its lifecycle in the durable store, and the driver's live position is pushed to the rider.
flowchart LR
Trip[Trip Service] -->|"persist lifecycle"| TripDB[(Trip Store - durable, sharded)]
Ingest[Location Ingestion] -->|"matched driver's pings"| Push[Realtime / Push Service]
Push -->|"WebSocket: location + ETA + status"| Rider[Rider app]
Trip -->|"status changes"| Push
- The Trip Service writes each lifecycle transition (
matched → en route → in progress → completed) to the durable trip store — this is the record that must never be lost. - While the trip is active, the matched driver's incoming pings are forwarded to the Realtime/Push Service.
- The Push Service streams the driver's moving position, refreshed ETA, and status changes to the rider's app over a WebSocket — the live map.
4. Combined architecture
Putting the three paths together:
flowchart LR
Driver[Driver app] -->|"location pings"| Ingest[Location Ingestion]
Ingest -->|"last-write-wins"| Loc[(Location Store - in-memory, sharded)]
Ingest -->|"update bucket"| Geo[(Geospatial Index)]
Rider[Rider app] -->|"request ride"| Match[Matching Service]
Match -->|"drivers within R"| Geo
Match -->|"atomic assign (CAS)"| DriverDB[(Driver store - durable)]
Match -->|"create trip"| Trip[Trip Service]
Trip -->|"persist lifecycle"| TripDB[(Trip Store - durable)]
Ingest -->|"matched driver pings"| Push[Realtime / Push Service]
Trip -->|"status changes"| Push
Push -->|"WebSocket: loc + ETA + status"| Rider
The key shape to notice: the in-memory left side (location store + geospatial index) absorbs the firehose and answers proximity queries cheaply, while the durable right side (the Driver store and Trip store) holds the comparatively rare, important state transitions. The Matching Service is the bridge — it reads the cheap side and writes the strict side.
Note
The location store is disposable on purpose. If it restarts, drivers re-ping within seconds and it refills; meanwhile the durable trip store — the part that actually needs to survive crashes — was never in the ping's hot path at all.
Deep dives
1. Geospatial proximity search
The core query is "give me available drivers within radius R of this point." A naive approach scans every driver and computes distance — O(number of drivers) per request, hopeless at 200K drivers and thousands of queries/sec. You need a structure that lets you look at only the drivers near the point. The standard trick: divide the world into cells/buckets, put each driver into the bucket for their location, and to answer a query, look only in the rider's bucket and its neighbors. Three common ways to build that index, ordered by how well they handle uneven, dense areas:
Option A — Geohash (fixed-grid prefix encoding) (works, with caveats)
A geohash turns a latitude/longitude into a short string (e.g. 9q8yyk) where nearby points usually share a prefix. You store each driver under their geohash, and "find drivers near X" becomes "find drivers whose geohash shares X's prefix" — a fast prefix lookup. A longer prefix = a smaller cell (more precision). Redis's geo commands are built on this idea.
Rider at geohash 9q8yyk. A 5-char prefix "9q8yy" is a coarser cell
(~5 km across; add a 6th char for a finer ~1 km cell):
bucket 9q8yy → drivers d4, d9
but a driver just across the cell edge sits in 9q8yz → MISSED unless
you also scan the 8 neighboring cells (9q8yz, 9q8yw, ...).
(Geohash cells are rectangular, not square — width and height differ by
prefix length and latitude — so "cell size" is approximate.)
- Pro: dead simple — a string prefix on a key. Works with any plain key-value store; no special tree to maintain.
- Con: the boundary problem — a driver one meter across a cell edge has a different prefix, so you must always query the rider's cell plus its 8 neighbors to avoid missing close drivers. The grid is also fixed-resolution: one cell size is too coarse downtown and too fine in the countryside.
- Verdict: works, with caveats — a fine, common default for moderate scale, as long as you remember to search neighboring cells. The fixed grid is its weakness in dense areas.
Option B — Quadtree (adaptive grid that splits where it's dense) (alternative)
A quadtree splits a region into four quadrants; any quadrant holding more than some limit of drivers splits again into four, and so on. Dense areas (downtown) end up with many small cells; empty areas (a desert) stay as one big cell. To search, you descend to the cells covering your radius and read the drivers there.
┌───────────┬───────────┐
│ │ ┌──┬──┐ │ downtown quadrant is crowded,
│ (sparse) │ ├──┼──┤ │ so it keeps splitting into
│ │ └──┴──┘ │ smaller cells (≈ even driver count)
├───────────┼───────────┤
│ │ │ sparse quadrants stay large
└───────────┴───────────┘
- Pro: adapts to density — each leaf cell holds a roughly even number of drivers, so a downtown query doesn't return a huge bucket and a rural query isn't starved. Naturally handles the "dense areas" problem.
- Con: more complex to build and maintain; because drivers move constantly, cells split and merge, and rebalancing a live tree under ~50K updates/sec is real work. Crucially, that split/merge cost scales with writes, and this is a write-heavy workload of moving points — so under high churn a quadtree is actually worse for the ping firehose than a fixed grid, whose buckets never rebalance. Boundary queries still need neighbor checks.
- Verdict: attractive when driver density varies wildly across a city, but its rebalancing cost under constant movement makes it a poor fit for this write-heavy workload — which is precisely why fixed hierarchical cells (S2/H3) win here. Reach for it only when a fixed grid's buckets become lopsided and writes are calmer than this.
Option C — Hierarchical cell IDs (Uber-style, e.g. S2 / H3) (recommended)
Map the globe to a hierarchy of fixed cells with stable IDs at multiple resolutions (Google's S2, or Uber's H3 hexagons). Each driver is tagged with the cell ID at a chosen resolution. A proximity query computes the set of cell IDs that cover the search radius and reads exactly those buckets. It combines geohash's simple bucket lookup with a cleaner story for radius coverage — H3's hexagons in particular have a single neighbor class: six edge-adjacent cells, with none of the square grid's edge-vs-diagonal distance gap. (Hexagons aren't perfectly uniform — every H3 resolution has 12 pentagon cells and cell areas vary — but one neighbor class is still far cleaner than geohash's 8-square step.)
Rider at cell 8a2a1072b59ffff (H3 res 9).
k-ring(1) = that cell + its 6 hex neighbors → 7 buckets to scan
hexagons: one neighbor class — 6 edge-adjacent cells (no diagonal vs side gap)
need wider radius? use k-ring(2): 19 cells. Coarser? drop to res 8.
- Pro: the bucket lookup is as simple as geohash, but neighbor handling is cleaner (hexagons) and you can switch resolution by region. Battle-tested at ride-sharing scale; IDs are compact integers, so the index is cheap to shard and store.
- Con: another library/dependency and concept to learn; like any fixed grid it still needs a strategy for very dense cells (use a finer resolution there).
- Verdict: recommended — the production answer for a real ride-sharing system, the simplicity of buckets with much better radius and density behavior. Name S2/H3 in the interview to show you know how this is actually built.
| Approach | Cell shape | Density handling | Boundary handling | Complexity |
|---|---|---|---|---|
| Geohash | Fixed grid | Poor (one fixed size) | Must scan 8 neighbors | Lowest |
| Quadtree | Adaptive grid | Good (splits where dense) | Neighbor checks; live rebalancing | Higher |
| S2 / H3 cells | Hierarchical (hex for H3) | Good (per-region resolution) | Clean (uniform hex neighbors) | Medium (library) |
Boundary and dense-area cases (true for all three): always search the rider's cell and its neighbors, or a driver just across an edge is missed. In a dense downtown cell that returns hundreds of drivers, cap the result and rank by ETA, or drop to a finer resolution so each cell stays small. If a radius finds no drivers, widen it and retry before telling the rider "no cars available."
2. High-throughput location updates
This is the firehose: ~50K pings/sec, each one tiny and each one making the last one obsolete. The design follows from one observation: you only ever need a driver's current location, never their history (the trip's route is sampled separately). That changes everything about how you store it.
- Keep current location in memory, last-write-wins. Hold each driver's position in an in-memory key-value store (e.g. Redis) keyed by
driver_id. A new ping simply overwrites the old value — no append, no log, no merge. Because the data is disposable (a restart is refilled by the next round of pings), you don't pay for per-write disk durability, which is exactly what lets the path sustain 50K writes/sec. - Don't durably persist every ping. Writing each ping to a relational database would mean ~50K durable writes/sec of data you immediately discard — the classic mistake. If you need the trip's path for receipts or maps, sample the matched driver's location every few seconds into the trip record — a few writes per trip, not per ping.
- Shard by geographic region. No single server can hold every driver and take every ping. Partition the location store and geospatial index by region (city/metro), so London's load lives on London's shards and a busy city can't starve a quiet one. Each shard owns its drivers' current positions and answers proximity queries for its area. A pickup near a region/shard boundary issues the proximity query to the owning shard and the adjacent shard(s), then merges and ranks the combined candidates by ETA. This fan-out is rare — most riders are interior to a region, so their query hits a single shard — so it doesn't dominate cost; you only pay for the extra shard reads at the edges.
- The read path for matching. Matching reads the in-memory index for drivers near the pickup, then filters candidates against the authoritative
availabilitycolumn in the durable Driver store. It also drops stale drivers: a candidate whose last ping (updated_at/Driver.last_seen) is older than N seconds — say a few ping intervals — is treated as dead and excluded, which covers a driver whose app crashed or lost signal but never went cleanly offline. Setting a TTL on the in-memory location key gives the same effect for free: the entry simply expires and disappears from the index. Because the geospatial lookup is in-memory and sharded, the proximity query stays in the single-digit-to-tens-of-milliseconds range even under the full ping load. The ping write and the match read touch the location index at very different rates (~50K writes vs ~thousands of reads/sec), and neither blocks the other.
Caution
Do not confuse "current location" with "location history." The hot path stores only the latest point per driver (last-write-wins). Storing every ping durably is the bottleneck that sinks naive designs — keep history, if you need it, as a sampled side stream off the hot path.
3. Matching and assignment consistency
Most of the system can be loose, but the assignment must be exact: one driver, one rider. The danger is a race — two riders request rides at the same instant, both proximity searches return the same nearby driver, and both try to assign them. Without a guard, both succeed and you've double-booked the driver.
The fix is to make the assignment a single atomic conditional update on the availability column in the durable Driver store — flip the driver's availability AVAILABLE → ASSIGNED only if it is still AVAILABLE — so exactly one of the racing requests wins. Below, two ways to do that; either is acceptable, with optimistic the usual default.
Default — Optimistic concurrency / compare-and-swap (CAS) on driver availability (recommended)
Read the candidate driver's availability, then do a conditional update: set ASSIGNED only if availability is still AVAILABLE. This is a compare-and-swap (CAS) — no lock held, just one all-or-nothing conditional write. Exactly one racer's update matches; everyone else updates 0 rows and moves on to the next candidate driver.
Two riders race for driver d7 (availability AVAILABLE):
both proximity searches return d7 as nearest
Rider A: UPDATE driver SET availability='ASSIGNED' WHERE id=d7 AND availability='AVAILABLE' → 1 row ✓
Rider B: UPDATE ... WHERE id=d7 AND availability='AVAILABLE' → 0 rows (already ASSIGNED)
→ A gets d7; B updated nothing, so B picks the next candidate (d3) and retries
- Pro: no locks, so it's fast and scales — the assignment is a single conditional write. The "lost" rider just retries with the next-best driver, which is exactly the behavior you want.
- Con: under heavy contention for the same hot driver (a surge with few cars), many requests fail and retry, doing wasted work. In practice contention is spread across many drivers, so this is rare.
- Verdict: recommended — the right default. Cheap, correct, and the retry-with-next-candidate fallback is natural for matching.
Alternative — Distributed lock / lease on the driver in Redis
Before assigning, acquire a short-lived lock on the driver — set a key lock:driver:{id} only if absent, with a small TTL (the "set only if absent" check and the expiry are one atomic step). Whoever gets the lock assigns the driver, updates the trip, then releases it; if the lock is held, that rider skips to the next candidate. The TTL means a crashed matcher's lock frees itself.
Two riders race for driver d7:
Rider A: SET lock:driver:d7 A NX EX 5 → OK (A holds the lock)
Rider B: SET lock:driver:d7 B NX EX 5 → nil (already locked) → try next driver
A assigns d7, creates trip, DEL lock:driver:d7
If A crashes mid-assign → lock auto-expires after 5s, d7 becomes claimable again
- Pro: explicit serialization point; handy when assignment involves several steps that must not interleave (reserve driver, write trip, notify) and you want them under one guard.
- Con: a lock is more machinery than a single CAS, and a TTL that expires mid-assignment needs a re-check guard (just like the CAS approach) so you don't act on an expired lock. Adds Redis as a coordination dependency.
- Verdict: reasonable when the assignment is multi-step, but for the simple availability flip the CAS is simpler and enough.
Timeouts and re-offer. Assignment isn't done when you flip availability — the driver still has to accept. So the flow is: assign (AVAILABLE → ASSIGNED), offer the ride to that driver, and start a short timer (e.g. 15 seconds). If the driver accepts, the trip proceeds to en route. If they decline or the timer expires, release the driver back to AVAILABLE and re-offer to the next-best candidate. This loop is why availability needs an intermediate ASSIGNED (offered, not yet accepted) state distinct from ON_TRIP, and why the release must also be atomic — otherwise a declined driver could be stuck ASSIGNED forever.
Warning
Do not treat the availability flip as the end of matching, and do not let location pings rewrite availability. A driver can ignore or decline the offer, so you need an accept timeout and a re-offer loop — and the release back to AVAILABLE must be atomic, or declined drivers leak out of the pool.
Tradeoffs & bottlenecks
- In-memory location vs durability. Keeping current location in memory with last-write-wins is what makes 50K pings/sec affordable, but it means the location store is not a source of truth you can rebuild — you accept that a restart loses in-flight positions and relies on the next pings. That's the right trade because the durable trip store, which does matter, is kept off this path.
- Geospatial index choice. Geohash is simplest but has fixed cells and the boundary problem; quadtree adapts to density but is costly to rebalance under constant movement; hierarchical cells (S2/H3) are the production middle ground. The trade is simplicity vs how gracefully you handle dense downtowns.
- Consistency scope. The system is mostly loose (approximate locations, best-effort pings) but strict on exactly one place — the match. Pinpointing where you need strong consistency, instead of making everything strong, is the central design judgment.
- Optimistic vs lock-based assignment. CAS is lock-free and fast but retries under contention; a lock serializes cleanly but adds machinery. Surge in a low-supply area concentrates contention on few drivers, which is the case that stresses either choice.
- Hot region shards. Sharding by region keeps load local, but a single hot city (or a stadium letting out) makes one shard hot. You handle it by sub-sharding that region and capping/ranking dense-cell query results.
- Ping frequency vs freshness. Pinging every 4s instead of every 1s cuts the write load 4× but makes the map and ETA slightly staler. You tune the interval against the firehose cost.
Extensions if asked
If the interviewer wants to push beyond the core matching/location/trip loop, add only the extension that changes the design discussion. (These are standalone topics; there's no dedicated guide for each yet.)
- Surge pricing. Compute a price multiplier per region from the live ratio of open requests to available drivers — a real-time analytics pipeline over the same location data, kept out of the matching hot path.
- ETA and routing. Replace straight-line distance with real road-network ETAs from a maps/routing service (traffic-aware), used both to rank candidate drivers and to show the rider an accurate arrival time.
- Driver and rider ratings. After each completed trip, collect a rating and aggregate it onto the driver/rider profile — a separate write-light service that the matching path can optionally factor in.
- Payments. Charge the rider and pay the driver after
completed, with idempotent charging so a retry can't double-bill — a separate ledger subsystem, deliberately out of scope here. - Trip route recording. Sample the matched driver's position into the durable trip record for receipts, maps, and dispute resolution — a few writes per trip, off the ping firehose.
What interviewers look for & common mistakes
What interviewers usually reward:
- A geospatial index for proximity search, not a table scan — and naming a concrete approach (geohash, quadtree, or S2/H3) with its boundary/density tradeoffs.
- A write-optimized, in-memory location path with last-write-wins, explicitly not durably persisting every ping, sharded by region.
- Separating the loose path from the strict path — approximate locations and best-effort pings, but an exact, atomic match.
- Exactly-one-rider assignment via a conditional update (CAS) or lock, plus an accept timeout and re-offer loop.
- A push channel (WebSocket) for live driver position and ETA, instead of having the rider poll every second.
Before you finish, do a quick mistake check:
- Did you use a geospatial index for "drivers near X," not a full scan?
- Did you keep current location in memory (last-write-wins) and avoid durably storing every ping?
- Did you shard the location store and index by region?
- Did you make the match atomic so two riders can't grab one driver?
- Did you handle the driver declining or timing out with a re-offer?
- Did you push live updates to the rider instead of polling?
- Did you keep the durable trip store off the high-throughput ping path?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →