# Design a Ride-Sharing Service: Near, Now, Claimed Once (2026)

> A ride-sharing service looks like a map with cars on it and is really three problems: finding nearby drivers among half a million that move every few seconds, ranking them by real travel time, and offering the ride so that no driver is promised to two riders. The prompt, the numbers, the API, and the design.

*Source: https://www.infowok.com/design-a-ride-sharing-service/ · Sukhveer Kaur · Published October 24, 2026*

---

You tap "Request ride". A few seconds later a driver three minutes away accepts, and a small car icon starts moving toward you on the map. It feels like one tap and one answer.

Behind it, an assumed half a million cars are moving. Each one reports its position every few seconds. In that moment the system must find the drivers near you, work out which one can really reach you soonest by road, ask that driver, and make sure nobody else is promised the same car.

Three words sum up the problem: **near, now, claimed once.** This lesson builds the system around one rule: **index by place, rank by road, claim once.**

<Prerequisites>

- The [interview framework](/system-design-interview-framework-2026/) and [estimation primer](/back-of-envelope-estimation-primer/).
- [Design a chat system](/design-a-chat-system/) for held connections, [design a rate limiter](/design-a-rate-limiter/) for atomic checks, and [design a web crawler](/design-a-web-crawler/) for leases, because all three return here.

</Prerequisites>

## Before You Start

<Callout type="note" title="Remember from the web crawler lesson">
How did the crawler make sure a fetcher that crashed did not lose its URL? It handed URLs out with a lease: a claim with a time limit, so unanswered work came back. An offer to a driver is the same idea, with a person on the other end.
</Callout>

<KeyTakeaways>

- Finding nearby drivers is a spatial lookup: cut the map into cells and search the rider's cell and its neighbours. Cell size trades cells read against drivers examined.
- Keep only the latest position of each driver in memory, because the live set is small and the writes are the problem.
- Rank a few candidates by road travel time, not straight-line distance, and match riders in short batches when drivers are scarce.
- An offer is a fast claim with an expiry, and the accept is a conditional write on a durable ride record, so a driver is never promised to two riders and never stays blocked.
- A ride is a state machine of stored events, and each transition is applied at most once because it checks the version it expects.

</KeyTakeaways>

<LessonOutcome>

Design a ride-sharing service end to end in 45 minutes: state your assumptions, index drivers by location, ingest their updates, rank candidates by road time, offer rides with expiring claims, choose consistency per component, and keep ride state correct. The production side (failures, hot cells, regions, surge, payments, privacy, targets) is in [the follow-ups lesson](/design-a-ride-sharing-service-follow-ups/).

</LessonOutcome>

## Step 1: Pin Down the Problem

Ask before drawing. Each answer you assume becomes a number:

- One city or many? (Assume many, but a ride never leaves its city, so cities can be independent shards.)
- One vehicle type, or several? Pooled or scheduled rides? (Assume one type, none of the others.)
- Does the system pick the driver, or does the rider browse cars? (Assume the system picks.)
- How fresh must a driver position be? (Assume a few seconds.)
- Are pricing, payments and fraud in scope? (Assume not here. The follow-ups lesson covers them.)

**Functional requirements:**

1. Drivers go online and share their location.
2. A rider requests a ride from a pickup point to a destination.
3. The system finds a nearby driver, offers the ride, and on acceptance links the two.
4. Both see the ride progress live, until it is completed or cancelled.

**Out of scope** (say these out loud): pricing and surge, payments, fraud, driver onboarding and ratings, pooled and scheduled rides.

**Non-functional requirements**, each with a number. These are assumptions you announce:

- Fast: a rider sees a driver matched within a few seconds when drivers are nearby (assume the median under 10 s).
- Correct: a driver is never matched to two riders at once, and a rider is never charged for two rides from one tap.
- Available: a location service hiccup must not stop rides already in progress.
- Scale: assume 500,000 drivers online at peak across all cities. Step 4 turns that into numbers.

## Step 2: Entities, API and Data Model

```text
POST /v1/rides
  {pickup, dropoff, request_id}
  201 new ride
  200 repeat of a request_id
  409 same id, different body
  422 pickup outside area
  429 too many requests

GET  /v1/rides/{id}
POST /v1/rides/{id}/cancel
GET  /v1/rides?cursor=...
     (history, paged)

Driver connection (held):
  location {lat, lng, heading,
            speed, ts, seq}
POST /v1/offers/{id}/accept
POST /v1/offers/{id}/decline
  200 matched
  409 OFFER_EXPIRED or taken
```

All calls carry a bearer token. A rider may read only their own rides, and a driver sees the pickup only once an offer is made.

Two fields matter most. `request_id` is made by the rider's app, so a retry of the same tap never creates a second ride; a repeat with a different body is a bug, so it fails with 409 (payment APIs such as Stripe's [compare repeat requests with the original](https://docs.stripe.com/api/idempotent_requests) for the same reason). `expires_at` on an offer means a driver who does not answer stops blocking the ride.

#### Data model

| Data | Where | Why |
|---|---|---|
| Live driver position: `driver:{id}` and `cell:{id}` sets | In memory, per region, expiring after about 30 s | Overwritten every 4 s, rebuilt in seconds |
| Ride: `ride_id`, `request_id`, rider, driver, state, `version`, pickup, dropoff | Durable store, partitioned by city, conditional writes | The record that must never be wrong |
| Ride events: `ride_id`, `seq`, type, time | Append-only, same store | The source for both apps and for audits |
| Offer: `offer_id`, ride, driver, `expires_at`, status | Memory with expiry, outcome copied to events | Short-lived, but the outcome is history |
| Location trail for trips | Stream, then cheap object storage | Disputes and analysis, never read on the hot path |

Access patterns drive the choices: ride by `ride_id`, ride by (`rider`, `request_id`), the active ride of a driver. Any store with single-row conditional writes works, such as a relational database or DynamoDB with [condition expressions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html), where a write goes ahead only if the condition holds.

## Step 3: A Design That Works

Start with the obvious design. Drivers report their positions into a table. When a rider asks for a ride, the server computes the distance from the rider to every driver, sorts, and takes the nearest few.

![First working design, scan every driver: for each ride request the server computes the distance to every driver in the city, sorts them and returns the five nearest, which is 20,000 distance checks in a city with 20,000 drivers](./design-a-ride-sharing-service-v1.svg)

It is correct, and for a small town it is fine. It is where many candidates stop.

<Callout type="tip" title="Predict first">
Assume a city has 20,000 drivers online and a rider is in the east of it. How many drivers does the server check, and how many of them could possibly be the answer?
</Callout>

All **20,000** (an assumed city size) are checked, and the answer is almost certainly among the few dozen nearest. Most of the work is wasted on drivers on the other side of the city. A toy test shows the gap: a grid index examined ≈ 338 drivers per request instead of 20,000, and returned the same five drivers every time. The rest of the design is about making "near" cheap.

## Step 4: Estimate What Matters

```text
Assume 500,000 drivers online
Update every 4 s:
  500,000 / 4 = 125,000/s
Per update ~100 B on the wire:
  125,000 x 100 B = 12.5 MB/s
  = 100 Mbit/s inbound
Latest position: 100 B each
  500,000 x 100 B = 50 MB

Rides: 5M a day / 86,400
  ≈ 58/s, peak ≈ 290/s
Rides in progress (20 min each):
  5M x 1,200 s / 86,400
  ≈ 69,000 on average
```

Then the neighbourhood:

```text
Dense area: 40 drivers/km2
2 km radius: π x 2 x 2
  ≈ 12.6 km2
  ≈ 500 candidates
Driver at 50 km/h
  ≈ 56 m moved per 4 s
Keep every point for a day:
  12.5 MB/s x 86,400
  ≈ 1.1 TB/day
```

#### What each number decides

- **≈ 125,000 updates a second** is the real load, almost all writes. The 100 Mbit/s inbound spreads over many gateways.
- The live set is only ≈ **50 MB**. It fits in memory on one machine, so the problem is write rate and latency, not size.
- ≈ **290 ride requests a second** at peak is small next to the updates. Even 290 × 500 candidates is 145,000 distance checks a second, trivial for memory. Ingest is the bottleneck, not matching.
- ≈ **1.1 TB a day** if every point were kept. That is why the trail keeps only points from trips in progress and expires them, not the whole idle fleet.
- A driver moves ≈ **56 m** between updates. Phone GPS is typically good to [about 4.9 m under open sky and worse near buildings](https://www.gps.gov/node/70), so 56 m of staleness dominates. That is fine for finding candidates, but the final ETA must account for it.
- All of these are assumptions for the arithmetic. Measure your own.

![Assumed load numbers for ride sharing: 125,000 location updates a second from 500,000 drivers, about 290 ride requests a second at peak, only 50 MB of current locations, about 500 drivers within 2 km in a dense area, and a driver moving about 56 metres between updates](./design-a-ride-sharing-service-scale.svg)

## Step 5: Deep Dives

Four questions decide this design. Take them in this order unless the interviewer pulls you elsewhere.

### How Do You Find Nearby Drivers Fast?

<Concept href="/consistent-hashing-explained/" title="Hashing keys evenly">
Turning a location into a cell id is a hash with a useful property: nearby places get nearby ids. That is what lets a lookup read a small neighbourhood instead of everything.
</Concept>

#### Scan everything

<DesignOption tier="works" title="Scan every driver">
Compute the distance to all drivers and sort, as in step 3.

<Tradeoff>Cost grows with the number of drivers, not with how many are close. At the assumed 20,000 drivers per city, every request checks all 20,000.</Tradeoff>
</DesignOption>

#### Grid cells

<DesignOption tier="scales" title="A grid: cells, a 3 x 3 block, and rings">
Cut the map into square cells and file each driver under the cell that holds it. For a request, look in the rider's cell and the eight cells around it, then widen by a ring if too few drivers are close enough. The search is exact once the fifth-nearest driver is closer than the distance the searched cells guarantee to cover.

A toy test on an assumed 25 × 20 km city with 1 km cells found the five nearest drivers for 2,000 random requests. With 20,000 drivers (40 per km²) the grid examined ≈ 338 per request, not 20,000, and every answer matched. With 1,000 drivers (2 per km²) it examined ≈ 24, and 27% of requests needed a second ring.

Real stores give you this. Redis's [geospatial commands](https://github.com/redis/docs/blob/main/content/commands/geoadd.md) keep points in a sorted set, interleaving latitude and longitude bits into a 52-bit geohash that fits a sorted-set score, and [`GEOSEARCH`](https://github.com/redis/docs/blob/main/content/commands/geosearch.md) finds members in a radius or box, sorted by distance. There is no delete command: use `ZREM`.

<Tradeoff>Straight-line distance is only a first filter. The grid finds candidates, and a later step ranks them by real travel time.</Tradeoff>
</DesignOption>

#### Hierarchical cells

<DesignOption tier="survives" title="Hierarchical cells, finer where it is dense">
Cities are uneven: a downtown cell holds hundreds of drivers and a suburb cell holds one. Hierarchical indexes let you choose the cell size per area. H3, Uber's open-source index, uses [hexagonal cells where each cell has seven children](https://github.com/uber/h3/blob/master/website/docs/highlights/indexing.md), changing resolution takes a few bitwise operations, and geographically close places tend to have numerically close indexes. Uber's H3 post gives the reason for hexagons: a cell has [only one distance between its centre and each neighbour's](https://www.uber.com/blog/h3/), where squares have two kinds of neighbour.

The costs are real: the same sources say a child cell is only approximately inside its parent, and every H3 resolution has [exactly twelve pentagons](https://h3geo.org/docs/core-library/restable/) among its cells. The H3 page also warns that where exact boundaries matter, the grid should be an optimisation on top of a precise check.

<Tradeoff>More moving parts: a rule for choosing resolution, and a precise distance check after the cell lookup. Start with a single resolution and add levels only when density varies a lot.</Tradeoff>
</DesignOption>

#### Which index?

All three common choices turn a point into a number.

| Index | Cells | Strength | Cost |
|---|---|---|---|
| Geohash (Redis GEO) | Rectangles from interleaved bits, 52-bit score | Already in Redis, one command for radius search | Neighbours across a cell edge can have far-apart numbers, so the store reads several ranges; cell size follows the bit count |
| S2 | Squares on six cube faces, four children, [64-bit ids ordered along a Hilbert curve](https://s2geometry.io/devguide/s2cell_hierarchy) | Exact nesting; nearby ids are nearby cells, so a database can answer a region with id range scans | Neighbour lookup is a little more code; level 13 is [about 1.27 km²](https://s2geometry.io/resources/s2cell_statistics.html), level 16 about 0.02 km² |
| H3 | Hexagons, seven children, [resolutions 0 to 15](https://h3geo.org/docs/core-library/restable/) | One neighbour distance, simple `gridDisk` rings, good for per-cell counts such as demand | Child cells only approximately inside the parent; pentagons |

Recommendation (judgement): for an in-memory index with a rider radius search, H3 or Redis GEO both work; choose H3 if you also want per-cell supply and demand counts, and S2 if the index lives in a sorted key-value store that serves range scans.

#### What cell size?

The H3 table lists resolution 8 at an average [0.737 km² and 0.53 km edge](https://h3geo.org/docs/core-library/restable/), and resolution 9 at 0.105 km² and 0.20 km. A [`gridDisk`](https://h3geo.org/docs/api/traversal/) of `k` returns every cell within `k` hops, which is 3k(k+1)+1 cells.

```text
Target: 2 km radius = 12.6 km2
Neighbour spacing = 1.73 x edge

Res 8: spacing ≈ 0.92 km
  k = 3 -> 37 cells x 0.737
  = 27.3 km2  -> 1,090 drivers
Res 9: spacing ≈ 0.35 km
  k = 6 -> 127 cells x 0.105
  = 13.4 km2  ->   535 drivers
(at 40 drivers/km2)
```

Using the [H3 areas](https://h3geo.org/docs/core-library/restable/), resolution 8 does 37 lookups and examines about 1,090 drivers, and resolution 9 does 127 lookups and about 535. Measure which is cheaper (judgement). Downtown, at an assumed 400 drivers/km², a resolution 8 cell holds ≈ 295 drivers, so dense areas want a finer one. The ring counts are rough: a precise distance check makes the result exact, and the search widens if fewer than five pass.

![Cell size trade-off for a 2 km search: H3 resolution 8 reads 37 cells covering 27.3 km2 and about 1,090 drivers, resolution 9 reads 127 cells covering 13.4 km2 and about 535 drivers, against a 12.6 km2 search circle](./design-a-ride-sharing-service-cells.svg)

![A grid index: the map is cut into square cells, drivers are filed under their cell, a request looks in the rider cell and the eight around it, and if fewer than five drivers are close enough the search widens by one more ring](./design-a-ride-sharing-service-grid.svg)

### How Do You Ingest 125,000 Updates a Second?

<Concept href="/design-a-chat-system/" title="Design a chat system">
Drivers hold a connection to a gateway, as chat clients do. The updates arrive through it, and only the latest one matters.
</Concept>

#### Write every update

<DesignOption tier="works" title="Write every update to a database">
Each update is a row insert or update in a table of drivers.

<Tradeoff>At ≈ 125,000 writes a second on rows that are overwritten every few seconds, the database does the most work on the data that matters least.</Tradeoff>
</DesignOption>

#### In memory

<DesignOption tier="scales" title="Update an in-memory index in place">
Keep the live positions in memory, in the grid or in a geo sorted set, and update a driver's entry in place. When a driver crosses into a new cell, remove them from the old cell and add them to the new one. At about 50 MB the live set is tiny, so one process per region holds it all.

Make the write safe to repeat and to reorder. Each update carries a per-driver sequence number `seq`, and the index ignores one that is not newer than the stored one, so a delayed packet never moves a car backwards. Raw phone GPS is noisy, so snap points to the road network with map matching, which [Valhalla provides](https://github.com/valhalla/valhalla/blob/master/README.md).

<Tradeoff>Memory is not durable. If the process restarts the index is empty until drivers report again, which takes seconds at a 4 second interval. That is acceptable for locations, but not for ride state.</Tradeoff>
</DesignOption>

#### By region

<DesignOption tier="survives" title="Regions, adaptive rates and a separate history">
Give each region its own location service, with one writer per region so updates never race. This is sharding by place. Let the app adapt its rate: faster when a driver is close to a pickup, slower when idle, which is a judgement call. Mark a driver offline when no update has arrived for a set time, such as 30 seconds. Send history to a separate stream for analysis so it never slows the live index.

<Tradeoff>Riders near a region border need both regions' drivers, so the search reads two shards. Pick region borders along places few trips cross, such as rivers and city limits.</Tradeoff>
</DesignOption>

![Location updates: driver apps send their position every few seconds over a held connection, the location service updates the in-memory index for the driver region, and history goes to a stream without slowing the live index](./design-a-ride-sharing-service-ingest.svg)

### How Do You Match Without Double-Booking?

<Concept href="/design-a-web-crawler/" title="Design a web crawler">
A lease: work handed out with a time limit, so a silent worker does not hold it forever. An offer to a driver works the same way.
</Concept>

#### Nearest wins

<DesignOption tier="works" title="Take the nearest and tell the driver">
Pick the nearest driver on the map and send the ride.

<Tradeoff>Two riders in the same minute can pick the same driver. And the nearest on the map can be far by road, for example across a river.</Tradeoff>
</DesignOption>

#### Rank, then claim

<DesignOption tier="scales" title="Rank by road time, then claim the driver">
Take the ≈ 5 best candidates from the grid and rank them by travel time. OSRM has a [Table service that returns durations between all pairs of coordinates](https://github.com/Project-OSRM/osrm-backend/blob/master/README.md), and [Valhalla offers a time and distance matrix](https://github.com/valhalla/valhalla/blob/master/README.md). One call ranks all candidates.

Then claim the driver atomically. In Redis, [`SET` with `NX` only sets a key that does not exist, and `PX` gives it an expiry in milliseconds](https://github.com/redis/docs/blob/main/content/commands/set.md). One rider's claim holds the driver, a second rider's attempt fails, and if the driver never answers the claim expires by itself.

<Tradeoff>An offer holds the driver for its whole window. At an assumed 15 seconds per driver, three declines cost up to 45 seconds, so keep the window short.</Tradeoff>
</DesignOption>

#### Sequence and batches

<DesignOption tier="survives" title="Offer in sequence, widen, and match in batches">
If the first driver declines or the claim expires, release it and offer to the next one. If candidates run out, widen the search ring. Make every step safe to repeat by keying it on the rider's `request_id`. When one step must update several keys together, such as the driver's claim and the ride's state, use an atomic script: Redis [guarantees a script's atomic execution](https://github.com/redis/docs/blob/main/content/develop/programmability/eval-intro.md).

When riders and drivers are both scarce, matching one rider at a time is greedy. Uber describes the alternative: [evaluate nearby riders and drivers in one batch in the seconds after a request, aiming to reduce the average wait for everyone, not just the closest pair](https://www.uber.com/us/en/marketplace/matching/). Pairing riders to drivers so total cost is smallest is an assignment problem, and solvers such as the Hungarian algorithm handle a batch of hundreds quickly (judgement on size).

<Tradeoff>Batching adds a small delay to every rider to improve the average. Test it with real demand before adopting it.</Tradeoff>
</DesignOption>

#### Why batching wins

Assume two riders and two drivers with pickup ETAs in minutes (made-up numbers):

```text
        D1   D2
R1       2    3
R2       3    9

Greedy, R1 first:
  R1-D1 (2) then R2-D2 (9) = 11
Batch, best pairing:
  R1-D2 (3) + R2-D1 (3)   =  6
```

The batch gives an average wait of 3 minutes against 5.5. If a batch window is 2 s and requests arrive at the peak of 290/s, a batch holds about 580 riders, a real assignment problem. Because a wide batch costs rider wait, shrink the window when supply is plentiful and widen it when it is scarce (judgement).

![Batch matching against greedy: two riders and two drivers with pickup ETAs of 2, 3, 3 and 9 minutes; greedy pairs R1 with D1 and leaves R2 a 9 minute wait for a total of 11, while the batch pairs R1 with D2 and R2 with D1 for a total of 6](./design-a-ride-sharing-service-batch.svg)

![Matching: find nearby drivers from the grid, rank them by road ETA from a routing service, offer to the best one with an expiring claim, and on a decline or timeout offer to the next driver](./design-a-ride-sharing-service-matching.svg)

### What Do You Pick Under a Partition?

The claim must not double-book, even when a node fails, so state a stance per component rather than a slogan. CAP and PACELC give the words.

| Component | During a partition | Otherwise | Why |
|---|---|---|---|
| Live locations | Stay available, serve stale positions | Low latency | A 30 s old car is a small error |
| Offer claim | Refuse to offer rather than risk two | A few ms more for the check | A double booking costs a rider and a driver |
| Ride record | Consistent: one owner per ride, reject on doubt | Conditional write per transition | The record is the truth for money and trust |
| Rider and driver pushes | Available, may be late | Low latency | The app can fetch the state again |

#### The claim is a fast filter, not the final word

Redis uses [asynchronous replication, so acknowledged writes can be lost during a failover](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/), and `WAIT` does not turn it into a strongly consistent system. After a failover a claim can vanish and a second rider could claim the same driver.

So the accept is a conditional write on the durable ride record, such as "set driver = D and state = matched only if state is offered and the offer id is X". Only one write passes, and the loser gets a 409. A stale holder is also why locks use [fencing tokens](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html): each offer carries an increasing number, and the store rejects anything older.

In PACELC terms this is PC/EC for claims and ride state and PA/EL for locations (judgement). **Idempotent accept and decline** follow from the same write: a double tap finds the ride already matched to that driver and returns 200 again; an accept after the offer expired fails with 409 and the app shows "no longer available". When the 15 s timer and the accept race, both are conditional writes on the same version, so exactly one wins.

### How Do You Keep Ride State Correct?

<Concept href="/design-a-notification-service/" title="Design a notification service">
Store the record first, then tell people. Both apps learn about a ride the same way: from stored events, with a nudge on top.
</Concept>

#### Polling

<DesignOption tier="works" title="Both apps poll the server">
The rider's and driver's apps ask for the ride's state every few seconds.

<Tradeoff>Delays of seconds and many wasted requests. It works, but a ride feels laggy.</Tradeoff>
</DesignOption>

#### Stored events

<DesignOption tier="scales" title="A state machine with stored events and held connections">
A ride moves through fixed states: requested, offered, matched, driver arriving, in progress, completed, with cancellation allowed before the ride starts. Store every transition as an event first, then push it to both apps over their held connections. If an app drops and reconnects, it resumes from its last event number, as in the [chat lesson](/design-a-chat-system/). While the app is in the background, a mobile push nudge wakes it, as in the notification lesson.

<Tradeoff>Two writers can race, for example a cancel and an accept at the same moment. Let one place decide, such as a single owner per ride, and reject any transition that is not legal from the current state.</Tradeoff>
</DesignOption>

#### Idempotent transitions

<DesignOption tier="survives" title="Idempotent transitions and regional shards">
Key every transition on a request or event id, so a retry that arrives twice is idempotent and changes nothing the second time. Every update says "from version v to v+1", so a repeat fails the check and changes nothing. That is how at-least-once delivery becomes an effect that happens once. Shard ride state by city, since rides rarely cross cities.

<Tradeoff>A regional shard can be overloaded while the rest sit idle, and a city outage stops its rides. The follow-ups lesson covers hot cells and failover.</Tradeoff>
</DesignOption>

| From | To | Trigger | Rule |
|---|---|---|---|
| requested | offered | Matcher picks a driver | Needs the driver claim |
| offered | offered | Decline or timeout | Next offer, version +1 |
| offered | matched | Driver accepts | Conditional on offer id and version |
| matched | driver arriving | Driver starts navigation | Driver only |
| arriving | in progress | Pickup confirmed | Driver or rider app |
| in progress | completed | Drop-off confirmed | Triggers payment |
| requested to arriving | cancelled | Rider or driver cancels | Fee rules are product choices |

![The ride as a state machine: requested, offered, matched, driver arriving, in progress and completed, with cancellation possible before the ride starts, and each transition stored as an event so retries are safe](./design-a-ride-sharing-service-states.svg)

<Callout type="key" title="A rule to remember">
**Index by place, rank by road, claim once.** A grid tells you who is near, a routing engine tells you who is soonest, and a claim backed by a conditional write makes sure only one rider gets them.
</Callout>

## The Final Design in One Picture

![Final design: driver apps stream locations to a regional location service with a live grid index, a matching service finds nearby drivers, ranks them by road ETA from a routing service and offers with an expiring claim, ride state and events are stored, and notifications go to both apps](./design-a-ride-sharing-service-final.svg)

Follow one ride:

1. Drivers send their position every few seconds. The regional location service updates its in-memory grid.
2. A rider taps request. The app sends a `request_id` with the pickup and destination.
3. The matching service reads the cells around the pickup and takes the closest candidates.
4. It asks the routing service for travel times from those candidates to the pickup, and ranks them, in a batch when drivers are scarce.
5. It claims the best driver with an expiring key and sends an offer. If the driver declines or the claim expires, it releases the claim and offers to the next one.
6. On acceptance a conditional write stores the ride as matched, and both apps are notified. Later transitions are stored first, then announced.

## What Each Level Should Show

| Level | Expected in the room |
|---|---|
| Mid-level | A driver table, a distance query, a notification to the driver. May need a hint about scale and spatial indexes. |
| Senior | A grid or geohash index, in-memory live locations, ETA ranking by road, an expiring claim on the driver, a ride state machine, an idempotent request. |
| Staff+ | Cell size arithmetic, batch matching, a per-component consistency stance with fencing, regional shards, hot cells, and what to build first. |

## Red Flags and Green Flags

<AnswerCompare>
<Weak label="Red flag">
"Store driver locations in a database and query the nearest ones." Nothing about the assumed 125,000 writes a second, road distance, or two riders getting the same driver.
</Weak>
<Strong label="Green flag">
"The live positions are only tens of megabytes, so I'll keep them in memory per region in a grid. I'll rank the few nearest by road time from a routing service, then claim the driver with an expiring key and make the accept a conditional write, so one rider gets them even after a failover."
</Strong>
</AnswerCompare>

<AnswerCompare>
<Weak label="Red flag">
"Redis SET NX makes sure nobody is double-booked." It ignores asynchronous replication, a paused holder, and a claim that expires while the driver is tapping accept.
</Weak>
<Strong label="Green flag">
"The claim is a fast filter. The truth is a conditional write on the ride record, with a version, so a late accept gets a 409."
</Strong>
</AnswerCompare>

<Callout type="warning" title="The most common way to lose this round">
Spending the time on the map and the "nearest driver" query and never reaching the double booking. The interviewer is waiting for the write load, the claim and what happens when a driver does not answer.
</Callout>

## Try It: Choose the Cell Size

<Exercise title="Resolution 8 or 9?" time="10 min">

A downtown zone has **400 drivers per km²**, a suburb has **10 per km²**. Using the H3 areas above (resolution 8 is 0.737 km², resolution 9 is 0.105 km²):

1. How many drivers does one cell hold downtown and in the suburb, at each resolution?
2. Which resolution would you use for each zone, and why?
3. What happens in the suburb if a rider's 3-ring search finds only 2 drivers?

<Hint>

Multiply density by cell area. Then ask which is worse: a cell with hundreds of drivers to examine, or a cell with none.

</Hint>

<Expected title="One reasonable answer">

```text
Downtown 400/km2:
  res 8: 400 x 0.737 ≈ 295
  res 9: 400 x 0.105 ≈  42
Suburb 10/km2:
  res 8: 10 x 0.737 ≈ 7
  res 9: 10 x 0.105 ≈ 1
```

Downtown, use resolution 9 (or finer): 42 drivers per cell is cheap to scan, while 295 per cell wastes work. In the suburb, use resolution 8: at resolution 9 most cells are empty, so a search needs many rings.

If a 3-ring search finds only 2 of the 5 wanted drivers, widen to the next ring and repeat, as the toy test did in 27% of requests at 2 drivers per km². Keep a maximum radius, after which the rider is told no cars are available and is offered a retry.

</Expected>

</Exercise>

<InInterview>

Expect the follow-ups in this order: "How do you find drivers near a rider?", "How do you handle 125,000 location updates a second?", "What if two riders request the same driver?", and "What happens when a driver does not answer?" Each one maps to a deep dive above. Close each answer with one trade-off and one figure. Then they will ask what breaks, how a stadium crowd, surge pricing, payments and location privacy work: that is the [follow-ups lesson](/design-a-ride-sharing-service-follow-ups/).

</InInterview>

## Check Yourself

<CheckYourself>

<Question q="Why does a grid beat scanning every driver, and when does it need a second ring?">

It looks at the rider's cell and its neighbours, a few hundred drivers instead of all of them. It needs a second ring when the area is sparse and fewer than the wanted number of drivers are close enough, which happened in 27% of requests at 2 drivers per square kilometre in the toy test.

</Question>

<Question q="Why keep driver locations in memory, and what do you lose on a restart?">

The live set is tiny but overwritten every few seconds, so a database wastes effort. A restart empties the index until drivers report again, which takes seconds. That is fine for locations, but not for ride state.

</Question>

<Question q="Two riders choose the same driver in the same second, and the Redis primary fails over right after. What stops a double booking?">

The Redis claim stops the first collision, but replication is asynchronous, so the new primary may not have it. The accept is a conditional write on the durable ride record, which succeeds for one offer only. The other rider gets a 409 and moves to the next candidate.

</Question>

<Question q="A rider's app retries the request after a timeout. How do you avoid two rides?">

The app sends the same `request_id` each time. The server stores the ride keyed by it, so a repeat finds the existing ride and returns it instead of creating another. The same id with a different body fails with 409.

</Question>

<Question q="When does batch matching beat nearest-first, and what does it cost?">

When drivers are scarce: the first rider may take the driver a second rider needed (11 minutes against 6 in the example). It costs every rider a short wait, so shrink the window when supply is plentiful.

</Question>

</CheckYourself>

<Callout type="tip" title="Learning tip">
Tomorrow, say the rule aloud, then trace one ride from tap to match in two minutes. For each step name what could fail (a restart, a decline, a double tap) and which part handles it.
</Callout>

## In One Picture

```text
Requirements
  locate, request, match, track
Scale
  125,000 updates/s · 50 MB live
Near
  cells sized to density
Soonest
  road ETA, batch when scarce
Claim
  fast key + conditional write
State
  versioned events, one owner
Rule
  index by place, rank by
  road, claim once
```

## Conclusion

A ride-sharing service is a live map plus a careful handshake. Keep the latest position of every driver in a spatial index in memory, rank a few candidates by real travel time, and claim the winner with an expiring key backed by a conditional write so only one rider gets them. Store every ride transition before announcing it, and shard by city.

The same ideas, cheap lookups for "near", leases for work that can go quiet, and stored events for state, run through most of the problems in this module.

**Which would you defend first in an interview: the grid, the ETA ranking or the claim?** Tell us in the comments.

<NextSteps>

- **What breaks, and the money side:** [the follow-ups lesson](/design-a-ride-sharing-service-follow-ups/) on failures, hot cells, surge, payments and privacy.
- **Held connections:** [design a chat system](/design-a-chat-system/), where a dropped app resumes.
- **Atomic checks and expiring claims:** [design a rate limiter](/design-a-rate-limiter/) and [design a web crawler](/design-a-web-crawler/).
- **Splitting by place:** the [lesson on splitting and copying data](/database-sharding-and-replication-explained/).

</NextSteps>
