System Design (High-Level Design) Module 25 – Case studies: location, mobility and logistics
Design ride hailing like Uber or Ola: matching
Design ride-hailing matching like Uber or Ola: driver positions in hexagonal cells, the shortest pickup by road, offers that never double-book, batch matching.
What you will learn
- Estimate position writes, bandwidth, index memory and ride requests for a city and a country
- Design the location write path from driver phones to an in-memory index of cells, sharded by city
- Find the driver with the shortest pickup by reading rings of cells until no unread cell can hold a faster one
- Implement offers that never give a driver two rides, with conditional writes, versions and timeouts
- Compare greedy matching with batch matching every two seconds, and plan for peaks and failures
Before you start
What you’ll learn · Before you start
What you will learn
- Estimate position writes, bandwidth, index memory and ride requests for a city and a country
- Design the location write path from driver phones to an in-memory index of cells, sharded by city
- Find the driver with the shortest pickup by reading rings of cells until no unread cell can hold a faster one
- Implement offers that never give a driver two rides, with conditional writes, versions and timeouts
- Compare greedy matching with batch matching every two seconds, and plan for peaks and failures
Before you start
On this page
Ride-hailing matching pairs a rider who has just asked for a car with the driver who can reach them soonest: at the evening peak, 231 requests a second while 600,000 drivers each report a position every 4 seconds. Matching is a small read path on top of a much larger write path.
Four moves do it. Driver positions land in an in-memory index of hexagonal cells, one shard per city. A request reads rings of cells around the rider and ranks the drivers it finds by pickup time over the roads, not by straight-line distance. The ride is offered to one driver at a time through a conditional update, so that no driver can hold two rides. And where riders crowd one area, a batch of requests matched together every two seconds cuts the total pickup time. Each deep dive below is answered with a program you can run.
Not affiliated
Based on public information about Uber and Ola. MySmartCoPilot is not affiliated with Uber and Ola, and Uber and Ola’s real architecture may differ from what this lesson describes.
One rider, one car, and no driver ever holding two rides
Ask these questions before drawing anything, because each answer changes the design. The answers below are the assumptions this case study uses.
- What is matched? One rider with one car. Shared rides and rides booked for later are out of scope.
- How big? A country with about 100 cities, 600,000 drivers online at the evening peak, 90,000 of them in the largest city, and 8 million trips a day.
- How fresh must positions be? Each driver’s phone reports every 4 seconds, so at city speeds a position is at most about 24 m old.
- What does “the best driver” mean? The shortest pickup time over the roads, among drivers with the vehicle type the rider chose. Not the nearest in a straight line: a river or a one-way street changes the order.
- How fast? The rider should see a confirmed driver within seconds; each offer waits at most 12 seconds for the driver’s answer.
- Where does it end? When a driver accepts. Fares, the trip itself and payments are the next case study.
So drivers go online and offline and stream their positions; riders request a ride with a pickup point and a vehicle type, may cancel, and watch the assigned driver approach; the system ranks candidates by pickup time, offers the ride to one driver at a time and confirms the match. The qualities matter more than the features. No driver ever holds two rides, and no rider gets two drivers. Finding candidates takes tens of milliseconds, so the driver’s answer is what the rider waits for. Positions may be lost, because the next report replaces them within seconds, but a driver’s state, available or on a trip, may never be lost. And matching copes with peaks that bring many times the usual requests into a few square kilometres within minutes.
Checkpoint
Check the scope
About 650 position writes for every ride request
The sheet below states every assumption as a constant, so anyone can disagree with one and run it again:
// The location write path, sized from stated assumptions. Change any constant and run it again.
const ONLINE_NATIONAL = 600_000; // drivers online at the evening peak, whole country
const ONLINE_BIG_CITY = 90_000; // of whom in the largest city
const WIRE_BYTES = 120; // one position report with framing and encryption overhead
const INDEX_BYTES = 160; // one driver in the in-memory index: id, position, cell, time, state
const HISTORY_BYTES = 60; // one stored position row, compressed
const DAY_SHARE = 0.4; // drivers online over the whole day, as a share of the peak
const SPEED = 6; // metres a second in city traffic
const TRIPS_A_DAY = 8_000_000;
const PEAK_FACTOR = 2.5; // the busiest hour against the day's average
const ETAS_PER_REQUEST = 25; // median from candidates.py: the drivers whose pickup time is computed
const fmt = (n, digits = 0) => {
const [whole, frac] = n.toFixed(digits).split('.');
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return frac ? `${grouped}.${frac}` : grouped;
};
const pad = (s, n) => String(s).padStart(n);
console.log('report writes/s, writes/s, inbound metres moved history');
console.log('every country big city Mbit/s between reports GB a day');
for (const interval of [2, 4, 8]) {
const national = ONLINE_NATIONAL / interval;
const city = ONLINE_BIG_CITY / interval;
const mbit = (national * WIRE_BYTES * 8) / 1e6;
const history = (national * DAY_SHARE * 86_400 * HISTORY_BYTES) / 1e9;
console.log(
`${`${interval} s`.padEnd(6)}${pad(fmt(national), 12)}${pad(fmt(city), 12)}${pad(fmt(mbit), 10)}${pad(fmt(interval * SPEED), 18)}${pad(fmt(history), 10)}`,
);
}
const memory = (drivers) => (drivers * INDEX_BYTES) / 1e6;
console.log(`\nindex memory: ${fmt(memory(ONLINE_NATIONAL))} MB for the country, ${fmt(memory(ONLINE_BIG_CITY), 1)} MB for the big city`);
const requests = (TRIPS_A_DAY / 86_400) * PEAK_FACTOR;
console.log(`ride requests at the peak: ${fmt(requests)} a second`);
console.log(`pickup times to compute: ${fmt(requests * ETAS_PER_REQUEST)} a second`); Output
report writes/s, writes/s, inbound metres moved history every country big city Mbit/s between reports GB a day 2 s 300,000 45,000 288 12 622 4 s 150,000 22,500 144 24 311 8 s 75,000 11,250 72 48 156 index memory: 96 MB for the country, 14.4 MB for the big city ride requests at the peak: 231 a second pickup times to compute: 5,787 a second
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node location_ingest.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Read it from the row for 4 seconds. Phones send 150,000 position writes a second across the country and 22,500 in the largest city, about 144 Mbit/s of incoming traffic. The index that holds every online driver takes 96 MB, so one machine could hold the whole country; sharding by city is for isolation and for spreading writes, not for size. The history of positions grows by about 311 GB a day. Riders send only 231 requests a second at the peak, and each request computes about 25 pickup times, close to 5,800 a second.
The comparison that shapes the design is 150,000 writes against 231 requests: about 650 position writes for every ride request, so the write path is designed first. The interval column shows the main lever. Reporting every 2 seconds doubles the writes for positions that are at most 12 m old; every 8 seconds halves them, and a car moves up to 48 m between reports.
Checkpoint
Check the estimate
Positions can be lost, driver states never: the API and the data
Drivers keep one connection open, and both directions use it:
wss://…/v1/drivers/streamis a WebSocket: A protocol, RFC 6455, that upgrades one HTTP request into a long-lived connection that carries messages in both directions. More in “Real-time delivery: polling, SSE and WebSockets”. The phone sendspositionmessages (latitude, longitude, heading, speed, accuracy and the time of the fix),statuschanges and answers to offers (acceptordeclinewith the offer id); the server sendsoffermessages andoffer-cancelled.POST /v1/ridestakes the pickup point, the destination, the vehicle type and anIdempotency-Keyheader, and answers202 Acceptedwith a ride id;GET /v1/rides/{rideId}returns its state, andPOST /v1/rides/{rideId}/cancelcancels it with any offer still waiting.
Stripe’s API reference describes the idempotency key behaviour the ride request needs: the server saves the result
of the first request made with a key, returns it to every retry, and may remove keys once they are at least 24 hours
old (Stripe). The offer id plays the same role for the driver: an
accept sent twice for the same offer gets the same answer twice.
The data splits along the line of the heading, positions in memory and decisions in durable stores:
| Data | Where it lives | Key | What it holds |
|---|---|---|---|
| Latest position | Geo index in memory, one shard per city | driver id, and cell to drivers | Position, cell, heading, time of the fix, vehicle type |
| Driver state | Driver state store, replicated and durable | driver id | Offline, available, offered, on the way or on a trip; a version number; the current offer or ride |
| Offer | Driver state store | offer id | Ride, driver, when it was made, when it expires, the answer |
| Ride request | Ride store | ride id; (rider, idempotency key) unique | Rider, pickup point, vehicle type, state, the assigned driver |
| Position history | Position log, then object storage | driver and time | Every report, for receipts, disputes and analytics |
The version number on each driver state is what makes offers safe: every change to a driver’s state checks the version it read and writes the next one, so two changes based on the same read cannot both succeed.
Checkpoint
Check: The API, and data that may or may not be lost
Positions flow into memory, offers through a durable store
The first complete design of ride-hailing matching
Text description of the diagram
The diagram shows two paths that meet at the geo index and the connection gateways.
- The location path: the driver app sends its position every 4 seconds over a WebSocket to the connection gateways, which pass it to the location service, sharded by city. The location service writes the latest position into the geo index in memory and sends positions in batches to a position log kept for history.
- The request path: the rider app sends a ride request through the API gateway to the matcher of the rider's city.
- The matcher reads candidate drivers from the geo index in rings of cells and asks the ETA service for pickup times over the roads.
- The matcher writes the offer to the driver states with a conditional update. The offer is then pushed to the driver's phone through the connection gateways, and an accepted offer goes to the trip service, which records the trip.
Positions arrive at the connection gateways, which hold the drivers’ WebSockets and pass each report to the location service of the driver’s city; it updates the driver’s entry in the geo index and adds the report to a log in batches. A request reaches the matcher of the rider’s city, which reads the drivers in the cells around the pickup point, asks the ETA service for their pickup times over the road network, and picks the best one. The offer is a conditional update in the driver state store, pushed to the driver’s phone through the gateway that holds its connection. Step through one ride:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A position. Every 4 seconds the driver's phone sends its position over the WebSocket that a connection gateway holds open.
- Into the index. The gateway passes the report to the location service of the driver's city, which updates the driver's entry in the geo index in memory.
- A request. A rider asks for a car. The request reaches the matcher of the rider's city through the API gateway.
- Candidates. The matcher reads the drivers in the cells around the pickup point from the geo index.
- Pickup times. It asks the ETA service for their pickup times over the road network, and picks the driver who arrives first.
- The offer. The offer is a conditional update in the driver state store, which succeeds only if the driver is still available.
- To the driver. The offer is pushed to the driver's phone through the gateway that holds its connection, and the answer comes back the same way.
- Accepted or not. An accepted offer becomes a trip. A declined or expired one sends the matcher to the next driver.
Each driver moves through five states, and every arrow is one checkable change:
The life of one driver in matching
Text description of the diagram
The diagram shows the five states of one driver and the arrows between them.
- An offline driver becomes available when the driver goes online; an available driver is in the geo index.
- An available driver becomes offered one ride through a conditional update, which succeeds only if the state and the version are still the ones the matcher read.
- An offered driver becomes available again when the driver declines, when the offer times out or when the rider cancels.
- An offered driver who accepts while the offer is still live is on the way to the rider, then on a trip once the rider is picked up, and available again when the trip ends.
- A dashed arrow leads from available to offline: the driver goes offline, or the phone stops reporting positions and the entry expires.
The rest of this case study is the five deep dives an interviewer asks about: ingesting positions, finding the driver with the shortest pickup, offers that never give a driver two rides, matching one at a time or in batches, and peaks.
Checkpoint
Check the design
A position every 4 seconds changes its cell once in about 11 reports
The connection. A phone opens one WebSocket when the driver goes online and keeps it open. Mobile networks drop connections without saying so, and a gateway that does not notice keeps sending offers into a dead socket. RFC 6455 gives the remedy: a Ping frame may serve as a keepalive or to verify that the other end is still responsive, and an endpoint that receives one must answer with a Pong (RFC 6455). The gateway pings every few seconds and closes connections that stop answering; the phone reconnects with backoff and jitter, as timeouts, retries and backoff describes.
The cell. The location service turns each position into a cell. Hexagonal H3: A grid of hexagons, plus 12 pentagons, that covers the world at 16 resolutions. Every neighbour of a hexagon is the same distance from its centre. More in “Geospatial indexing: geohash, quadtrees, S2, H3” cells suit this: H3 gives every hexagon six neighbours at the same distance (H3, aggregation), and at resolution 9 a hexagon averages 0.105 km² with edges of about 200 m (H3, cell statistics). S2 cells on a cube would work as well (S2 Geometry). The examples use hexagons with 200 m edges on a flat city map, a small stand-in for H3.
The write. If the driver is still in the same cell, the report overwrites the position in place; if the cell changed, the driver moves from one cell’s set to the other’s. At 6 m/s with a report every 4 seconds, only 8.7 % of reports change the cell, one in 11.5, as the first line of the next run measures. An entry older than three missed reports, about 15 seconds, is skipped by searches and later removed. Step through one driver’s reports:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Is it still there?. The gateway sends a Ping every few seconds. The phone must answer with a Pong, so a dead connection is noticed before an offer is sent into it.
- Same cell. A report 4 seconds later lands in the same cell, as most do. The location service overwrites the driver's position in its geo index, in place.
- A new cell. Now and then the driver crosses into the next cell, in 8.7 % of reports at 6 m/s in the run, one in 11.5. The location service moves the driver from one cell's set to the other's.
- History, in batches. Every report also goes to a log in batches, for receipts, disputes and analytics, never in the way of the index.
- The phone goes quiet. The driver enters a tunnel and reports stop. Nothing is sent to say so.
- Expired by age. An entry older than three missed reports, about 15 seconds, is skipped by searches and later removed. The driver drops out of matching without any message.
The shards. One city’s drivers fit easily in memory: 90,000 entries at 160 bytes are 14.4 MB. Sharding by city keeps every search inside one shard, spreads the 150,000 writes a second, and keeps a fault in one city away from the others. The largest cities are split further into regions of cells, and a search near a region’s border reads both shards. A replica of each shard is for fast failover, not for durability: a new shard is complete again after one round of reports, 4 seconds. Writing 150,000 history rows a second synchronously to a database would put a second system, larger than matching, in matching’s way, so history goes to the log in batches.
A ready-made store can do part of this. Redis keeps geospatial indexes in sorted sets, and GEOSEARCH returns the
members within a radius or a box of a point, nearest first with ASC; with COUNT n ANY it returns as soon as it has
found n matches, which is faster but may miss closer ones (Redis).
Either way, the search answers “who is near”, and the matcher still has to answer “who arrives first”.
Checkpoint
Check: Ingesting positions into cells, sharded by city
Rings of cells stop when no unread cell can hold a faster driver
A made-up city of 10 km by 10 km has a river through the middle, crossed by two bridges 6 km apart, and streets on a grid. The run places drivers and riders in it, for an evening peak and for a quiet night, and finds each rider’s best driver four ways, each checked against a brute-force search over every driver:
"""Finding the driver with the shortest pickup, from hexagonal cells.
A made-up city of 10 km by 10 km, in metres, with a river at x = 5,000 that cars cross only on two
bridges. Streets form a grid, so a car drives |dx| + |dy| on one bank, or goes by a bridge. Drivers
sit in hexagonal cells with 200 m edges, the size of H3's resolution 9, kept in a dict from cell to
driver ids. Every number below is an assumption of this model, chosen to be easy to change.
"""
import heapq
import math
import random
EDGE = 200.0 # hexagon edge in metres (H3 resolution 9 averages about 200.8 m)
SPEED = 6.0 # metres a second on city streets, about 22 km/h
RIVER_X = 5_000.0
BRIDGES_Y = (2_000.0, 8_000.0)
SIDE = 10_000.0
SQRT3 = math.sqrt(3)
DIRECTIONS = ((1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1))
rng = random.Random(7)
def clamp(v):
return min(max(v, 0.0), SIDE)
def place(share_downtown):
"""A point: downtown (around the city centre) or anywhere in the city."""
if rng.random() < share_downtown:
return clamp(rng.gauss(4_500, 1_200)), clamp(rng.gauss(5_000, 1_200))
return rng.uniform(0, SIDE), rng.uniform(0, SIDE)
def cell_of(x, y):
"""Axial coordinates of the pointy-top hexagon that holds (x, y), by cube rounding."""
cx = (SQRT3 / 3 * x - y / 3) / EDGE
cz = (2 / 3 * y) / EDGE
cy = -cx - cz
rx, ry, rz = round(cx), round(cy), round(cz)
dx, dy, dz = abs(rx - cx), abs(ry - cy), abs(rz - cz)
if dx > dy and dx > dz:
rx = -ry - rz
elif dy <= dz:
rz = -rx - ry
return rx, rz
def ring(center, k):
"""The cells exactly k steps from center: 6k of them, or the center itself when k = 0."""
if k == 0:
return [center]
q, r = center[0] + DIRECTIONS[4][0] * k, center[1] + DIRECTIONS[4][1] * k
out = []
for side in range(6):
for _ in range(k):
out.append((q, r))
q, r = q + DIRECTIONS[side][0], r + DIRECTIONS[side][1]
return out
def covered(k):
"""Every point within this many metres of the rider lies inside rings 0..k."""
return max(0.0, EDGE * (1.5 * k - 0.5))
def road_seconds(a, b):
"""Street-grid driving time; crossing the river means driving to a bridge first."""
(x1, y1), (x2, y2) = a, b
if (x1 < RIVER_X) == (x2 < RIVER_X):
metres = abs(x1 - x2) + abs(y1 - y2)
else:
metres = min(abs(x1 - RIVER_X) + abs(y1 - by) + abs(x2 - RIVER_X) + abs(y2 - by) for by in BRIDGES_Y)
return metres / SPEED
def pct(n, total):
return f"{100 * n / total:.1f} %"
def moving_reports():
"""How often does a report, every 4 s at 6 m/s, move a driver into another cell?"""
crossings = reports = 0
for _ in range(2_000):
x, y = rng.uniform(2_000, 8_000), rng.uniform(2_000, 8_000)
heading = rng.uniform(0, 2 * math.pi)
cell = cell_of(x, y)
for _ in range(30):
x += math.cos(heading) * SPEED * 4
y += math.sin(heading) * SPEED * 4
reports += 1
now = cell_of(x, y)
if now != cell:
crossings += 1
cell = now
print(f"Moving drivers: {pct(crossings, reports)} of reports change the driver's cell (one in {reports / crossings:.1f})")
def scenario(title, n_drivers, n_riders):
drivers = [place(0.5) for _ in range(n_drivers)]
riders = [place(0.6) for _ in range(n_riders)]
index = {}
for i, p in enumerate(drivers):
index.setdefault(cell_of(*p), set()).add(i)
def search(rider):
"""Read rings outwards until no unread cell can hold a driver faster than the best one found."""
home = cell_of(*rider)
best, best_s, cells, timed, k = None, math.inf, 0, 0, 0
while True:
for c in ring(home, k):
cells += 1
for d in index.get(c, ()):
timed += 1
s = road_seconds(drivers[d], rider)
if s < best_s:
best, best_s = d, s
# A road is never shorter than the straight line, so a driver more than covered(k)
# metres away needs more than covered(k) / SPEED seconds and cannot beat the best.
if best is not None and covered(k) >= best_s * SPEED:
return best_s, cells, timed
k += 1
extra = {"own cell, when not empty": [], "nearest in a straight line": [], "10 nearest, re-ranked by ETA": [], "rings until no faster driver": []}
empty_home, cells_read, timed_calls = 0, [], []
for rider in riders:
true_best = min(road_seconds(d, rider) for d in drivers)
shortlist = heapq.nsmallest(10, range(n_drivers), key=lambda d: math.dist(drivers[d], rider))
home = index.get(cell_of(*rider), ())
if home:
extra["own cell, when not empty"].append(min(road_seconds(drivers[d], rider) for d in home) - true_best)
else:
empty_home += 1
extra["nearest in a straight line"].append(road_seconds(drivers[shortlist[0]], rider) - true_best)
extra["10 nearest, re-ranked by ETA"].append(min(road_seconds(drivers[d], rider) for d in shortlist) - true_best)
s, cells, timed = search(rider)
extra["rings until no faster driver"].append(s - true_best)
cells_read.append(cells)
timed_calls.append(timed)
print(f"\n{title}: {n_drivers:,} available drivers, {n_riders:,} ride requests")
print(f"{'method':<31}{'worse than best':>16}{'extra s, mean':>15}{'extra s, worst':>16}")
for name, xs in extra.items():
worse = [e for e in xs if e > 1e-9]
mean = sum(worse) / len(worse) if worse else 0.0
print(f"{name:<31}{pct(len(worse), len(xs)):>16}{mean:>15.0f}{max(xs):>16.0f}")
cells_read.sort()
timed_calls.sort()
print(f"riders with no driver in their own cell: {pct(empty_home, n_riders)}")
print(f"rings rule, cells read: median {cells_read[n_riders // 2]}, 95th percentile {cells_read[int(n_riders * 0.95)]}")
print(f"rings rule, ETAs computed: median {timed_calls[n_riders // 2]}, 95th percentile {timed_calls[int(n_riders * 0.95)]} (the shortlist: 10)")
moving_reports()
scenario("Evening peak", 1_500, 1_000)
scenario("Late night", 150, 1_000) Output
Moving drivers: 8.7 % of reports change the driver's cell (one in 11.5) Evening peak: 1,500 available drivers, 1,000 ride requests method worse than best extra s, mean extra s, worst own cell, when not empty 22.8 % 22 660 nearest in a straight line 12.2 % 40 940 10 nearest, re-ranked by ETA 0.0 % 0 0 rings until no faster driver 0.0 % 0 0 riders with no driver in their own cell: 18.9 % rings rule, cells read: median 7, 95th percentile 19 rings rule, ETAs computed: median 25, 95th percentile 64 (the shortlist: 10) Late night: 150 available drivers, 1,000 ride requests method worse than best extra s, mean extra s, worst own cell, when not empty 17.1 % 98 888 nearest in a straight line 12.5 % 147 933 10 nearest, re-ranked by ETA 0.0 % 0 0 rings until no faster driver 0.0 % 0 0 riders with no driver in their own cell: 72.0 % rings rule, cells read: median 19, 95th percentile 91 rings rule, ETAs computed: median 6, 95th percentile 18 (the shortlist: 10)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 candidates.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Reading only the rider’s own cell fails twice. At the evening peak, 18.9 % of riders have no driver in their own cell at all, and at night 72 % have none; when the cell does hold drivers, the best one is elsewhere 22.8 % of the time. The nearest driver in a straight line is often not the first to arrive. It was worse than the best driver for about one rider in eight, by 40 seconds on average at the peak and 147 at night; the worst cases, more than 15 minutes, are drivers just across the river. Re-ranking the 10 nearest by pickup time found the best driver for every rider here, because on a street grid a road is at most about 1.4 times the straight line; nothing guarantees it where a long river or a highway with few exits stands in the way.
Reading rings until no unread cell can hold a faster driver is exact. No road is shorter than the straight line, so a driver more than (speed × best pickup time) away cannot beat the best found so far. After rings 0 to k, every point within 200 × (1.5k − 0.5) metres of the rider has been read, so the matcher stops once that radius reaches the best pickup time times the speed. It matched the brute force for all 2,000 requests, reading a median of 7 cells at the peak and 19 at night. Step through the rule:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Ring 0. The matcher reads the rider's own cell. Even if it holds a driver, nothing is certain yet, because the rider may sit near the cell's edge.
- Ring 1. Ring 1 adds the 6 neighbours, 7 cells in all. Every point within 200 m of the rider has now been read.
- The best so far. Say the fastest of the drivers found arrives in 30 s. At 6 m/s, a faster driver would have to be within 180 m in a straight line, because no road is shorter than the straight line.
- Stop after ring 1. 180 m is inside the 200 m already covered, so no unread cell can hold a faster driver. At the evening peak the median search stopped here, after 7 cells.
- At night. With few drivers, the best pickup is longer, and the search reads more rings. Ring 2 covers 500 m with 19 cells, the median at night, and ring 3 covers 800 m with 37.
H3 offers the same building blocks: gridDisk returns the cells at most k steps from a cell and gridRing those
exactly k steps away, 6k of them, and the documentation notes that the output order is not guaranteed and that
pentagons distort the grid (H3, traversal). A disk of k rings holds
3k(k + 1) + 1 cells: 7 for one ring, 19 for two, 37 for three.
In production the two ideas combine. Pickup times are the expensive part, each a route over a road graph with live speeds, about 5,800 a second at the peak. So the matcher takes a shortlist from the rings, asks the ETA service for all of it in one batched call, and keeps reading rings only while the stopping rule says an unread cell could still hold a faster driver. Precomputed travel times between cells, corrected by live traffic, make the call fast enough.
Checkpoint
Check: Rings of cells and the shortest pickup
A conditional write gives each driver one offer at a time
Three matchers serve a city, because one would be a single point of failure. Each reads the available drivers, spends
50 to 150 ms getting pickup times, then writes an offer. Two matchers that read the same driver as available both
choose that driver, a check-then-act: Code that checks a condition and then acts on it as two separate steps, such as reading that a seat is free and then inserting a ticket. Another request can run between the two, so both can pass the check. More in “Contention: holds, locks and no double booking” race, and a plain write lets both offers through.
The fix is a conditional update: An UPDATE whose WHERE clause carries the check, such as buyer IS NULL, so that checking and writing are one statement. Of two requests that race for the same row, only one changes it; the other changes no row. More in “Contention: holds, locks and no double booking” that checks what the matcher read, in the same
statement:
UPDATE drivers SET state = 'offered', offer_id = ?, version = version + 1 WHERE id = ? AND state = 'available' AND version = ?.
PostgreSQL reports how many rows an UPDATE changed, and a count of 0 is not an error
(PostgreSQL), so the matcher reads 0 as “someone else got
there first” and moves on. Redis gives the same check-and-set behaviour with WATCH, which aborts a transaction when a
watched key changed before EXEC (Redis). The
race in each language, with plain and with conditional writes:
Python 3.14 · main.py
class Driver:
def __init__(self) -> None:
self.state, self.version = "available", 7
# the driver state store
class Store:
def __init__(self, conditional: bool) -> None:
self.conditional = conditional
self.drivers = {"D": Driver(), "E": Driver()}
# rides promised, each with its driver
self.promised: list[str] = []
def read(self, name: str) -> int:
return self.drivers[name].version
def offer(self, matcher: str, ride: str, name: str, seen: int) -> bool:
driver = self.drivers[name]
fresh = driver.state == "available" and driver.version == seen
ok = fresh or not self.conditional # a plain write checks nothing
if ok:
driver.state, driver.version = "offered", driver.version + 1
self.promised.append(f"{ride} -> {name}")
# refused: the conditional update changed 0 rows
outcome = f"{'written' if ok else 'refused'}, version {driver.version}"
print(f" {matcher} offers {ride} to {name}, read at version {seen}: {outcome}")
return ok
for conditional in (False, True):
print("conditional writes" if conditional else "plain writes")
store = Store(conditional)
seen_by_a, seen_by_b = store.read("D"), store.read("D")
store.offer("A", "r1", "D", seen_by_a)
if not store.offer("B", "r2", "D", seen_by_b):
store.offer("B", "r2", "E", store.read("E")) # the next candidate
print(f" riders promised: {', '.join(store.promised)}") In Python: Driver objects are shared references, so offer() changes the stored driver in place, and fresh is the WHERE clause of the conditional update written as one boolean.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
// the driver state store
class Store {
constructor(conditional) {
this.conditional = conditional;
this.drivers = new Map(["D", "E"].map((name) => [name, { state: "available", version: 7 }]));
this.promised = []; // rides promised, each with its driver
}
read(name) {
return this.drivers.get(name).version;
}
offer(matcher, ride, name, seen) {
const driver = this.drivers.get(name);
const fresh = driver.state === "available" && driver.version === seen;
const ok = fresh || !this.conditional; // a plain write checks nothing
if (ok) {
Object.assign(driver, { state: "offered", version: driver.version + 1 });
this.promised.push(`${ride} -> ${name}`);
}
// refused: the conditional update changed 0 rows
const outcome = `${ok ? "written" : "refused"}, version ${driver.version}`;
console.log(` ${matcher} offers ${ride} to ${name}, read at version ${seen}: ${outcome}`);
return ok;
}
}
for (const conditional of [false, true]) {
console.log(conditional ? "conditional writes" : "plain writes");
const store = new Store(conditional);
const [seenByA, seenByB] = [store.read("D"), store.read("D")];
store.offer("A", "r1", "D", seenByA);
if (!store.offer("B", "r2", "D", seenByB)) {
store.offer("B", "r2", "E", store.read("E")); // the next candidate
}
console.log(` riders promised: ${store.promised.join(", ")}`);
} In JavaScript: Object.assign updates the stored driver object in place, the change every later read of the Map then sees.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class Main {
static final class Driver {
String state = "available";
int version = 7;
}
// the driver state store
static final class Store {
final boolean conditional;
final Map<String, Driver> drivers = Map.of("D", new Driver(), "E", new Driver());
final List<String> promised = new ArrayList<>(); // rides promised, each with its driver
Store(boolean conditional) {
this.conditional = conditional;
}
int read(String name) {
return drivers.get(name).version;
}
boolean offer(String matcher, String ride, String name, int seen) {
Driver driver = drivers.get(name);
boolean fresh = driver.state.equals("available") && driver.version == seen;
boolean ok = fresh || !conditional; // a plain write checks nothing
if (ok) {
driver.state = "offered";
driver.version++;
promised.add(ride + " -> " + name);
}
// refused: the conditional update changed 0 rows
String outcome = (ok ? "written" : "refused") + ", version " + driver.version;
String offer = " " + matcher + " offers " + ride + " to " + name;
System.out.println(offer + ", read at version " + seen + ": " + outcome);
return ok;
}
}
public static void main(String[] args) {
for (boolean conditional : new boolean[] {false, true}) {
System.out.println(conditional ? "conditional writes" : "plain writes");
Store store = new Store(conditional);
int seenByA = store.read("D");
int seenByB = store.read("D");
store.offer("A", "r1", "D", seenByA);
if (!store.offer("B", "r2", "D", seenByB)) {
store.offer("B", "r2", "E", store.read("E")); // the next candidate
}
System.out.println(" riders promised: " + String.join(", ", store.promised));
}
}
} In Java: Map.of is immutable, but the Driver objects it holds are not, so offer() changes a driver in place.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <map>
#include <print>
#include <string>
#include <vector>
struct Driver {
std::string state = "available";
int version = 7;
};
// the driver state store
struct Store {
bool conditional;
std::map<std::string, Driver> drivers = {{"D", {}}, {"E", {}}};
std::vector<std::string> promised; // rides promised, each with its driver
explicit Store(bool conditional) : conditional(conditional) {}
int read(const std::string &name) const { return drivers.at(name).version; }
bool offer(const std::string &matcher, const std::string &ride,
const std::string &name, int seen) {
Driver &driver = drivers.at(name);
const bool fresh = driver.state == "available" && driver.version == seen;
const bool ok = fresh || !conditional; // a plain write checks nothing
if (ok) {
driver = {"offered", driver.version + 1};
promised.push_back(ride + " -> " + name);
}
// refused: the conditional update changed 0 rows
std::println(" {} offers {} to {}, read at version {}: {}, version {}",
matcher, ride, name, seen, ok ? "written" : "refused",
driver.version);
return ok;
}
};
int main() {
for (const bool conditional : {false, true}) {
std::println("{}", conditional ? "conditional writes" : "plain writes");
Store store(conditional);
const int seen_by_a = store.read("D"), seen_by_b = store.read("D");
store.offer("A", "r1", "D", seen_by_a);
if (!store.offer("B", "r2", "D", seen_by_b)) {
store.offer("B", "r2", "E", store.read("E")); // the next candidate
}
std::string promised;
for (const auto &ride : store.promised) {
promised += (promised.empty() ? "" : ", ") + ride;
}
std::println(" riders promised: {}", promised);
}
} In C++: drivers.at(name) returns a reference, so assigning a new Driver to it replaces the stored one, and at throws for a name the map does not hold.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
foreach (bool conditional in new[] { false, true })
{
Console.WriteLine(conditional ? "conditional writes" : "plain writes");
var store = new Store(conditional);
int seenByA = store.Read("D"), seenByB = store.Read("D");
store.Offer("A", "r1", "D", seenByA);
if (!store.Offer("B", "r2", "D", seenByB))
{
store.Offer("B", "r2", "E", store.Read("E")); // the next candidate
}
Console.WriteLine($" riders promised: {string.Join(", ", store.Promised)}");
}
class Driver
{
public string State { get; set; } = "available";
public int Version { get; set; } = 7;
}
// the driver state store
class Store(bool conditional)
{
readonly Dictionary<string, Driver> drivers = new() { ["D"] = new(), ["E"] = new() };
public List<string> Promised { get; } = []; // rides promised, each with its driver
public int Read(string name) => drivers[name].Version;
public bool Offer(string matcher, string ride, string name, int seen)
{
var driver = drivers[name];
bool fresh = driver.State == "available" && driver.Version == seen;
bool ok = fresh || !conditional; // a plain write checks nothing
if (ok)
{
(driver.State, driver.Version) = ("offered", driver.Version + 1);
Promised.Add($"{ride} -> {name}");
}
// refused: the conditional update changed 0 rows
string outcome = $"{(ok ? "written" : "refused")}, version {driver.Version}";
string offer = $" {matcher} offers {ride} to {name}";
Console.WriteLine($"{offer}, read at version {seen}: {outcome}");
return ok;
}
} In C#: Driver is a class, so the dictionary holds references, and the tuple assignment to driver.State and driver.Version changes the stored driver.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stdio.h>
#include <string.h>
typedef struct {
const char *name, *state;
int version;
} Driver;
// the driver state store
typedef struct {
bool conditional;
Driver drivers[2];
const char *promised[2][2]; // rides promised, each with its driver
int promised_count;
} Store;
static Driver *find(Store *store, const char *name) {
return &store->drivers[strcmp(name, "D") == 0 ? 0 : 1];
}
static int read_driver(Store *store, const char *name) {
return find(store, name)->version;
}
static bool offer_ride(Store *store, const char *matcher, const char *ride,
const char *name, int seen) {
Driver *driver = find(store, name);
const bool fresh =
strcmp(driver->state, "available") == 0 && driver->version == seen;
const bool ok = fresh || !store->conditional; // a plain write checks nothing
if (ok) {
driver->state = "offered";
driver->version++;
store->promised[store->promised_count][0] = ride;
store->promised[store->promised_count++][1] = name;
}
// refused: the conditional update changed 0 rows
printf(" %s offers %s to %s, read at version %d: %s, version %d\n", matcher,
ride, name, seen, ok ? "written" : "refused", driver->version);
return ok;
}
int main(void) {
for (int conditional = 0; conditional <= 1; conditional++) {
puts(conditional ? "conditional writes" : "plain writes");
Store store = {.conditional = conditional,
.drivers = {{"D", "available", 7}, {"E", "available", 7}}};
const int seen_by_a = read_driver(&store, "D");
const int seen_by_b = read_driver(&store, "D");
offer_ride(&store, "A", "r1", "D", seen_by_a);
if (!offer_ride(&store, "B", "r2", "D", seen_by_b)) {
const int seen = read_driver(&store, "E"); // the next candidate
offer_ride(&store, "B", "r2", "E", seen);
}
printf(" riders promised: ");
for (int i = 0; i < store.promised_count; i++) {
printf("%s%s -> %s", i > 0 ? ", " : "", store.promised[i][0],
store.promised[i][1]);
}
printf("\n");
}
return 0;
} In C: The store keeps its two drivers in a fixed array, find returns a pointer into it, and designated initializers set only the fields the run needs, the rest zero.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
// the driver state store
final class Store
{
/** @var array<string, array{state: string, version: int}> */
private array $drivers = [
'D' => ['state' => 'available', 'version' => 7],
'E' => ['state' => 'available', 'version' => 7],
];
/** @var list<string> rides promised, each with its driver */
public array $promised = [];
public function __construct(private readonly bool $conditional) {}
public function read(string $name): int
{
return $this->drivers[$name]['version'];
}
public function offer(string $matcher, string $ride, string $name, int $seen): bool
{
$driver = &$this->drivers[$name];
$fresh = $driver['state'] === 'available' && $driver['version'] === $seen;
$ok = $fresh || !$this->conditional; // a plain write checks nothing
if ($ok) {
$driver = ['state' => 'offered', 'version' => $driver['version'] + 1];
$this->promised[] = "{$ride} -> {$name}";
}
// refused: the conditional update changed 0 rows
$outcome = ($ok ? 'written' : 'refused') . ", version {$driver['version']}";
echo " {$matcher} offers {$ride} to {$name}, read at version {$seen}: {$outcome}\n";
return $ok;
}
}
foreach ([false, true] as $conditional) {
echo ($conditional ? 'conditional writes' : 'plain writes') . "\n";
$store = new Store($conditional);
[$seenByA, $seenByB] = [$store->read('D'), $store->read('D')];
$store->offer('A', 'r1', 'D', $seenByA);
if (!$store->offer('B', 'r2', 'D', $seenByB)) {
$store->offer('B', 'r2', 'E', $store->read('E')); // the next candidate
}
echo ' riders promised: ' . implode(', ', $store->promised) . "\n";
} In PHP: PHP arrays are values, so offer() takes a reference, &$this->drivers[$name], to change the stored driver rather than a copy.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::BTreeMap;
struct Driver {
state: &'static str,
version: u32,
}
/// The driver state store.
struct Store {
conditional: bool,
drivers: BTreeMap<&'static str, Driver>,
promised: Vec<String>, // rides promised, each with its driver
}
impl Store {
fn new(conditional: bool) -> Self {
let available = || Driver {
state: "available",
version: 7,
};
let drivers = BTreeMap::from([("D", available()), ("E", available())]);
Store {
conditional,
drivers,
promised: Vec::new(),
}
}
fn read(&self, name: &str) -> u32 {
self.drivers[name].version
}
fn offer(&mut self, matcher: &str, ride: &str, name: &str, seen: u32) -> bool {
let Some(driver) = self.drivers.get_mut(name) else {
return false;
};
let fresh = driver.state == "available" && driver.version == seen;
let ok = fresh || !self.conditional; // a plain write checks nothing
if ok {
(driver.state, driver.version) = ("offered", driver.version + 1);
self.promised.push(format!("{ride} -> {name}"));
}
// refused: the conditional update changed 0 rows
let outcome = if ok { "written" } else { "refused" };
let version = driver.version;
let offer = format!(" {matcher} offers {ride} to {name}");
println!("{offer}, read at version {seen}: {outcome}, version {version}");
ok
}
}
fn main() {
for conditional in [false, true] {
let title = if conditional {
"conditional writes"
} else {
"plain writes"
};
println!("{title}");
let mut store = Store::new(conditional);
let (seen_by_a, seen_by_b) = (store.read("D"), store.read("D"));
store.offer("A", "r1", "D", seen_by_a);
if !store.offer("B", "r2", "D", seen_by_b) {
let seen = store.read("E"); // the next candidate
store.offer("B", "r2", "E", seen);
}
println!(" riders promised: {}", store.promised.join(", "));
}
} In Rust: get_mut borrows the stored driver mutably, and since that borrows only the drivers field, self.promised can still change in the same block.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
interface Driver {
state: string;
version: number;
}
// the driver state store
class Store {
readonly conditional: boolean;
drivers = new Map<string, Driver>(
["D", "E"].map((name) => [name, { state: "available", version: 7 }]),
);
promised: string[] = []; // rides promised, each with its driver
constructor(conditional: boolean) {
this.conditional = conditional;
}
driver(name: string): Driver {
const driver = this.drivers.get(name);
if (driver === undefined) throw new Error(`no driver ${name}`);
return driver;
}
read(name: string): number {
return this.driver(name).version;
}
offer(matcher: string, ride: string, name: string, seen: number): boolean {
const driver = this.driver(name);
const fresh = driver.state === "available" && driver.version === seen;
const ok = fresh || !this.conditional; // a plain write checks nothing
if (ok) {
Object.assign(driver, { state: "offered", version: driver.version + 1 });
this.promised.push(`${ride} -> ${name}`);
}
// refused: the conditional update changed 0 rows
const outcome = `${ok ? "written" : "refused"}, version ${driver.version}`;
console.log(` ${matcher} offers ${ride} to ${name}, read at version ${seen}: ${outcome}`);
return ok;
}
}
for (const conditional of [false, true]) {
console.log(conditional ? "conditional writes" : "plain writes");
const store = new Store(conditional);
const [seenByA, seenByB] = [store.read("D"), store.read("D")];
store.offer("A", "r1", "D", seenByA);
if (!store.offer("B", "r2", "D", seenByB)) {
store.offer("B", "r2", "E", store.read("E")); // the next candidate
}
console.log(` riders promised: ${store.promised.join(", ")}`);
} In TypeScript: Map.get may return undefined, so driver() checks it once and throws for a missing name, which narrows every later use to Driver.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"strings"
)
type driver struct {
state string
version int
}
// store is the driver state store.
type store struct {
conditional bool
drivers map[string]*driver
promised []string // rides promised, each with its driver
}
func (s *store) read(name string) int {
return s.drivers[name].version
}
func (s *store) offer(matcher, ride, name string, seen int) bool {
d := s.drivers[name]
fresh := d.state == "available" && d.version == seen
ok := fresh || !s.conditional // a plain write checks nothing
outcome := "refused" // refused: the conditional update changed 0 rows
if ok {
d.state, d.version = "offered", d.version+1
s.promised = append(s.promised, ride+" -> "+name)
outcome = "written"
}
fmt.Printf(" %s offers %s to %s, read at version %d: %s, version %d\n",
matcher, ride, name, seen, outcome, d.version)
return ok
}
func main() {
for _, conditional := range []bool{false, true} {
if conditional {
fmt.Println("conditional writes")
} else {
fmt.Println("plain writes")
}
drivers := map[string]*driver{"D": {"available", 7}, "E": {"available", 7}}
s := &store{conditional: conditional, drivers: drivers}
seenByA, seenByB := s.read("D"), s.read("D")
s.offer("A", "r1", "D", seenByA)
if !s.offer("B", "r2", "D", seenByB) {
s.offer("B", "r2", "E", s.read("E")) // the next candidate
}
fmt.Printf(" riders promised: %s\n", strings.Join(s.promised, ", "))
}
} In Go: The map holds *driver pointers, so changing d.state changes the stored driver; a map of driver values would hand back copies.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
struct Driver {
var state = "available"
var version = 7
}
/// The driver state store.
struct Store {
let conditional: Bool
var drivers = ["D": Driver(), "E": Driver()]
var promised: [String] = [] // rides promised, each with its driver
func read(_ name: String) -> Int {
drivers[name, default: Driver()].version
}
mutating func offer(_ matcher: String, _ ride: String, to name: String, seen: Int) -> Bool {
var driver = drivers[name, default: Driver()]
let fresh = driver.state == "available" && driver.version == seen
let ok = fresh || !conditional // a plain write checks nothing
if ok {
driver = Driver(state: "offered", version: driver.version + 1)
drivers[name] = driver
promised.append("\(ride) -> \(name)")
}
// refused: the conditional update changed 0 rows
let outcome = "\(ok ? "written" : "refused"), version \(driver.version)"
print(" \(matcher) offers \(ride) to \(name), read at version \(seen): \(outcome)")
return ok
}
}
for conditional in [false, true] {
print(conditional ? "conditional writes" : "plain writes")
var store = Store(conditional: conditional)
let (seenByA, seenByB) = (store.read("D"), store.read("D"))
_ = store.offer("A", "r1", to: "D", seen: seenByA)
if !store.offer("B", "r2", to: "D", seen: seenByB) {
_ = store.offer("B", "r2", to: "E", seen: store.read("E")) // the next candidate
}
print(" riders promised: \(store.promised.joined(separator: ", "))")
} In Swift: Driver is a struct, a value type, so offer() changes a copy and writes it back with drivers[name] = driver.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
Driver = Struct.new(:state, :version)
# the driver state store
class Store
attr_reader :promised
def initialize(conditional)
@conditional = conditional
@drivers = %w[D E].to_h { |name| [name, Driver.new('available', 7)] }
@promised = [] # rides promised, each with its driver
end
def read(name)
@drivers[name].version
end
def offer(matcher, ride, name, seen)
driver = @drivers[name]
fresh = driver.state == 'available' && driver.version == seen
written = fresh || !@conditional # a plain write checks nothing
write(driver, ride, name) if written
# refused: the conditional update changed 0 rows
outcome = "#{written ? 'written' : 'refused'}, version #{driver.version}"
puts " #{matcher} offers #{ride} to #{name}, read at version #{seen}: #{outcome}"
written
end
private
def write(driver, ride, name)
driver.state = 'offered'
driver.version += 1
@promised << "#{ride} -> #{name}"
end
end
[false, true].each do |conditional|
puts conditional ? 'conditional writes' : 'plain writes'
store = Store.new(conditional)
seen_by_a = store.read('D')
seen_by_b = store.read('D')
store.offer('A', 'r1', 'D', seen_by_a)
store.offer('B', 'r2', 'E', store.read('E')) unless store.offer('B', 'r2', 'D', seen_by_b)
puts " riders promised: #{store.promised.join(', ')}"
end In Ruby: Struct.new builds Driver with its accessors, and RuboCop allows methods of ten lines at most, so the write is a private method of its own.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
data class Driver(
val state: String = "available",
val version: Int = 7,
)
// the driver state store
class Store(
private val conditional: Boolean,
) {
private val drivers = mutableMapOf("D" to Driver(), "E" to Driver())
val promised = mutableListOf<String>() // rides promised, each with its driver
fun read(name: String): Int = drivers.getValue(name).version
fun offer(
matcher: String,
ride: String,
name: String,
seen: Int,
): Boolean {
var driver = drivers.getValue(name)
val fresh = driver.state == "available" && driver.version == seen
val ok = fresh || !conditional // a plain write checks nothing
if (ok) {
driver = Driver("offered", driver.version + 1)
drivers[name] = driver
promised.add("$ride -> $name")
}
// refused: the conditional update changed 0 rows
val outcome = if (ok) "written" else "refused"
val offer = " $matcher offers $ride to $name"
println("$offer, read at version $seen: $outcome, version ${driver.version}")
return ok
}
}
fun main() {
for (conditional in listOf(false, true)) {
println(if (conditional) "conditional writes" else "plain writes")
val store = Store(conditional)
val (seenByA, seenByB) = store.read("D") to store.read("D")
store.offer("A", "r1", "D", seenByA)
if (!store.offer("B", "r2", "D", seenByB)) {
store.offer("B", "r2", "E", store.read("E")) // the next candidate
}
println(" riders promised: ${store.promised.joinToString(", ")}")
}
} In Kotlin: Driver is an immutable data class, so an offer stores a new Driver in the map instead of changing the old one.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
class Driver {
var state = 'available';
var version = 7;
}
/// The driver state store.
class Store {
Store(this.conditional);
final bool conditional;
final drivers = {'D': Driver(), 'E': Driver()};
final promised = <String>[]; // rides promised, each with its driver
int read(String name) => drivers[name]!.version;
bool offer(String matcher, String ride, String name, int seen) {
final driver = drivers[name]!;
final fresh = driver.state == 'available' && driver.version == seen;
final ok = fresh || !conditional; // a plain write checks nothing
if (ok) {
driver
..state = 'offered'
..version += 1;
promised.add('$ride -> $name');
}
// refused: the conditional update changed 0 rows
final outcome = ok ? 'written' : 'refused';
print(
' $matcher offers $ride to $name, read at version $seen: $outcome, '
'version ${driver.version}',
);
return ok;
}
}
void main() {
for (final conditional in [false, true]) {
print(conditional ? 'conditional writes' : 'plain writes');
final store = Store(conditional);
final (seenByA, seenByB) = (store.read('D'), store.read('D'));
store.offer('A', 'r1', 'D', seenByA);
if (!store.offer('B', 'r2', 'D', seenByB)) {
store.offer('B', 'r2', 'E', store.read('E')); // the next candidate
}
print(' riders promised: ${store.promised.join(', ')}');
}
} In Dart: The cascade driver..state = ... ..version += 1 changes two fields of the stored object in one expression.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
plain writes A offers r1 to D, read at version 7: written, version 8 B offers r2 to D, read at version 7: written, version 9 riders promised: r1 -> D, r2 -> D conditional writes A offers r1 to D, read at version 7: written, version 8 B offers r2 to D, read at version 7: refused, version 8 B offers r2 to E, read at version 7: written, version 8 riders promised: r1 -> D, r2 -> E
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 11 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 153 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 22 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 720 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 191 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 13 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
With plain writes both riders are promised driver D. With conditional writes B’s offer finds version 8, not the 7 it read, changes nothing, and B offers its rider the next candidate, E. The run below plays two minutes of a city with 80 drivers and three matchers, including a concert that sends 40 requests from one spot within two seconds:
"""Three matchers, one pool of drivers: offers with and without a conditional update.
Each request is handled by one of three matcher instances. A matcher reads the available drivers,
spends 50 to 150 ms asking for road ETAs, then writes an offer to the best driver. The plain write
trusts what the matcher read; the conditional write applies only if the driver is still available
at the version the matcher read, like UPDATE ... WHERE state = 'available' AND version = ?.
A simulated clock in seconds; the same seed for both runs.
"""
import heapq
import random
DRIVERS, AREA, SPEED = 80, 4_000.0, 6.0
OFFER_TIMEOUT, GIVE_UP = 12.0, 60.0
VENUE = (2_000.0, 2_000.0)
def run(conditional):
rng = random.Random(11)
pos = [(rng.uniform(0, AREA), rng.uniform(0, AREA)) for _ in range(DRIVERS)]
state = ["available"] * DRIVERS
version = [0] * DRIVERS
live = [set() for _ in range(DRIVERS)] # rides offered to a driver and not yet answered
busy_with = [None] * DRIVERS # the ride a driver is driving to, if any
confirmed = {}
stats = {"offers written": 0, "a second live offer": 0, "refused writes, retried": 0,
"confirmed to a busy driver": 0, "busy drivers shown as free": 0, "no driver within 60 s": 0}
waits = []
events, seq = [], 0
def at(t, *event):
nonlocal seq
seq += 1
heapq.heappush(events, (t, seq, event))
riders = []
t = 0.0
while True:
t += rng.expovariate(0.4) # about one request every 2.5 seconds
if t > 120:
break
riders.append((t, (rng.uniform(0, AREA), rng.uniform(0, AREA))))
for _ in range(40): # a show ends: 40 requests at the venue in 2 seconds
riders.append((60 + rng.uniform(0, 2), (rng.gauss(VENUE[0], 300), rng.gauss(VENUE[1], 300))))
riders.sort()
rider_pos = {}
for ride, (when, where) in enumerate(riders):
rider_pos[ride] = where
at(when, "match", ride, when, frozenset())
def eta(d, ride):
(x1, y1), (x2, y2) = pos[d], rider_pos[ride]
return (abs(x1 - x2) + abs(y1 - y2)) / SPEED
while events:
now, _, event = heapq.heappop(events)
kind, ride, start, refused_by = event[:4]
if kind == "match":
if ride in confirmed:
continue
if now - start > GIVE_UP:
stats["no driver within 60 s"] += 1
continue
seen = [(eta(d, ride), d, version[d]) for d in range(DRIVERS) if state[d] == "available" and d not in refused_by]
if not seen:
at(now + 2.0, "match", ride, start, refused_by)
continue
_, d, v = min(seen)
at(now + rng.uniform(0.05, 0.15), "offer", ride, start, refused_by, d, v)
continue
d = event[4]
if kind == "offer":
if conditional and (state[d] != "available" or version[d] != event[5]):
stats["refused writes, retried"] += 1
at(now, "match", ride, start, refused_by)
continue
state[d] = "offered"
version[d] += 1
live[d].add(ride)
stats["offers written"] += 1
if len(live[d]) > 1:
stats["a second live offer"] += 1
roll = rng.random()
if roll < 0.10:
at(now + OFFER_TIMEOUT, "expire", ride, start, refused_by, d)
else:
at(now + rng.uniform(2, 10), "accept" if roll < 0.95 else "decline", ride, start, refused_by, d)
elif kind == "accept":
if conditional and not (state[d] == "offered" and ride in live[d]):
continue
live[d].discard(ride)
if busy_with[d] is not None:
stats["confirmed to a busy driver"] += 1
busy_with[d] = ride
confirmed[ride] = d
state[d] = "on the way"
version[d] += 1
waits.append(now - start)
else: # "decline" or "expire": try the next driver
if conditional and not (state[d] == "offered" and ride in live[d]):
continue
live[d].discard(ride)
if conditional:
if not live[d]:
state[d] = "available"
else:
if busy_with[d] is not None:
stats["busy drivers shown as free"] += 1
state[d] = "available" # the plain write trusts its own view of the driver
version[d] += 1
at(now, "match", ride, start, refused_by | {d})
stats["requests"] = len(riders)
stats["rides confirmed"] = len(confirmed)
stats["mean seconds to a driver"] = round(sum(waits) / len(waits), 1)
return stats
plain, guarded = run(False), run(True)
print(f"{'':<30}{'plain write':>12}{'conditional':>13}")
for key in ["requests", "offers written", "a second live offer", "busy drivers shown as free", "confirmed to a busy driver",
"refused writes, retried", "rides confirmed", "no driver within 60 s", "mean seconds to a driver"]:
print(f"{key:<30}{plain[key]:>12}{guarded[key]:>13}") Output
plain write conditional requests 84 84 offers written 101 95 a second live offer 12 0 busy drivers shown as free 3 0 confirmed to a busy driver 12 0 refused writes, retried 0 35 rides confirmed 84 80 no driver within 60 s 0 4 mean seconds to a driver 8.3 8.4
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 offers.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With plain writes, 12 offers landed on drivers who already had one, 12 rides were confirmed to a driver who was already busy, and 3 busy drivers were marked available again. The plain run “confirmed” all 84 requests, which is exactly the problem. With conditional writes none of that happened: 35 writes were refused and retried with the next candidate, at almost no cost (8.4 seconds to a driver against 8.3), and the last 4 riders of the two minutes found no driver within a minute, because by then each of the 80 drivers had a ride: the real shortage that double bookings had hidden. Four rules complete the offer:
- One live offer per driver. The matcher never offers a driver a second ride while one is waiting.
- Expiry is checked by the store. The accept is also a conditional update,
… WHERE id = ? AND state = 'offered' AND offer_id = ? AND expires_at > now(), using the database’s clock, so a late tap on an expired offer is refused instead of racing the next offer. - Answers are idempotent. An
acceptretried after a lost reply finds the offer already accepted and returns the same result. - Cancellation uses the same check. A rider who cancels while an offer is waiting frees the driver only if that offer is still the driver’s current one.
The timeout is a trade. With one driver in ten never answering, a 12-second timeout costs the average rider about 1.2 seconds, and a 30-second one about 3 seconds, while drivers who are driving need time to read the offer safely.
UUID Generator Make random version 4 UUIDs, the usual shape for offer ids and idempotency keys.Checkpoint
Check: Conditional offers, expiry and idempotent answers
A batch every 2 seconds pays only where drivers are scarce
Matching riders one at a time, in the order they arrive, is greedy: each rider takes the best driver left, even if a rider one second later needed that driver far more. Batch matching collects the requests of a two-second window and gives every rider a different driver so that the total pickup time is as small as possible. OR-Tools calls this the assignment problem, in which every worker gets one task at most, every task goes to a different worker and the total cost is as low as possible (OR-Tools), and offers a solver for it (OR-Tools). The run uses a small solver of its own, checks it against brute force, then compares the two policies in a busy district:
"""One rider at a time, or a batch every 2 seconds?
Greedy matching gives each rider, in arrival order, the free driver with the shortest pickup. Batch
matching collects the riders of a 2-second window and solves the assignment problem: give every rider
a different driver so that the total pickup time is as small as possible. The solver below finds
shortest augmenting paths with potentials, the Hungarian method, and is checked against brute force.
Pickup times are street-grid distances at 6 m/s in a busy district of 3 km by 3 km.
"""
import itertools
import math
import random
SPEED, AREA = 6.0, 3_000.0
def assign(cost):
"""Rows (riders) to distinct columns (drivers), len(cost) <= len(cost[0]), minimising the total cost."""
rows, cols = len(cost), len(cost[0])
row_pot = [0.0] * rows
col_pot = [0.0] * (cols + 1) # column `cols` is a virtual start column
owner = [-1] * (cols + 1) # the row that holds each column
for r in range(rows):
owner[cols] = r
dist = [math.inf] * (cols + 1)
came_from = [-1] * (cols + 1)
done = [False] * (cols + 1)
here = cols
dist[here] = 0.0
while True:
done[here] = True
row = owner[here]
step, nxt = math.inf, -1
for c in range(cols):
if done[c]:
continue
reduced = dist[here] + cost[row][c] - row_pot[row] - col_pot[c]
if reduced < dist[c]:
dist[c], came_from[c] = reduced, here
if dist[c] < step:
step, nxt = dist[c], c
here = nxt
if owner[here] == -1:
break
for c in range(cols + 1): # keep every reduced cost non-negative
if done[c]:
if owner[c] != -1:
row_pot[owner[c]] += step - dist[c]
col_pot[c] -= step - dist[c]
while here != cols: # flip the path: each column takes the row before it
back = came_from[here]
owner[here] = owner[back]
here = back
result = [-1] * rows
for c in range(cols):
if owner[c] != -1:
result[owner[c]] = c
return result
def pickup(a, b):
return (abs(a[0] - b[0]) + abs(a[1] - b[1])) / SPEED
rng = random.Random(5)
# The solver against brute force on small cases.
agree = 0
for _ in range(200):
n, m = rng.randint(2, 5), rng.randint(5, 7)
cost = [[rng.randint(30, 900) for _ in range(m)] for _ in range(n)]
got = sum(cost[i][j] for i, j in enumerate(assign(cost)))
best = min(sum(cost[i][p[i]] for i in range(n)) for p in itertools.permutations(range(m), n))
agree += got == best
print(f"solver equal to brute force on {agree} of 200 small cases\n")
def percentile(xs, q):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(q * len(xs)))]
print(f"{'drivers per rider':<19}{'greedy mean':>12}{'batch mean':>11}{'saved':>7}{'greedy p90':>12}{'batch p90':>10}{'greedy worst':>14}{'batch worst':>12}")
for ratio in (3.0, 1.5, 1.1):
greedy_all, batch_all = [], []
for _ in range(300):
riders = [(rng.uniform(0, AREA), rng.uniform(0, AREA)) for _ in range(12)]
drivers = [(rng.uniform(0, AREA), rng.uniform(0, AREA)) for _ in range(round(12 * ratio))]
free = set(range(len(drivers)))
for r in riders: # greedy, in arrival order
d = min(free, key=lambda j: pickup(drivers[j], r))
free.remove(d)
greedy_all.append(pickup(drivers[d], r))
cost = [[pickup(d, r) for d in drivers] for r in riders]
for i, j in enumerate(assign(cost)):
batch_all.append(cost[i][j])
g, b = sum(greedy_all) / len(greedy_all), sum(batch_all) / len(batch_all)
print(f"{ratio:<19}{g:>11.0f}s{b:>10.0f}s{100 * (g - b) / g:>6.0f}%{percentile(greedy_all, 0.9):>11.0f}s{percentile(batch_all, 0.9):>9.0f}s{max(greedy_all):>13.0f}s{max(batch_all):>11.0f}s")
print("\nA 2-second window makes each rider wait 1 second on average before matching starts.") Output
solver equal to brute force on 200 of 200 small cases drivers per rider greedy mean batch mean saved greedy p90 batch p90 greedy worst batch worst 3.0 62s 59s 4% 113s 106s 300s 343s 1.5 109s 98s 10% 211s 183s 578s 454s 1.1 158s 133s 16% 327s 252s 766s 558s A 2-second window makes each rider wait 1 second on average before matching starts.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 batch_match.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The solver matched brute force on all 200 small cases. With 3 drivers per rider, nearest drivers rarely collide, and batching saves about 4 %; at 1.1 drivers per rider it cuts the average pickup from 158 to 133 seconds, 16 %, and the 90th percentile from 327 to 252. The price is the window, one second of waiting on average, and one subtler cost: with plentiful drivers the batch’s worst pickup was 343 seconds against greedy’s 300. Answer two questions about an area, and the table marks the policy that fits:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Policy | Scarce: 1.1 per rider | Plentiful: 3 per rider | Waiting before matching | Worst single pickup |
|---|---|---|---|---|
| Greedy: each request at once | Poor 158 s mean pickup, 327 s at the 90th percentile | Good 62 s mean; nearest drivers rarely collide | Good none | Good 300 s with plentiful drivers |
| Batch: every 2 seconds | Good 133 s mean, 16 % shorter; 252 s at the 90th percentile | Mixed 59 s mean, only 4 % shorter | Mixed 1 second on average in a 2-second window | Poor 343 s: the smallest total can sacrifice one rider |
How the answers decide
- “How many idle drivers are there for each waiting rider in the area?” Three or more: adds a caveat to Batch: every 2 seconds. With plentiful drivers, batching saves about 4 % of pickup time and makes every rider wait a second first.
- “How many idle drivers are there for each waiting rider in the area?” About one: adds a caveat to Greedy: each request at once. Where drivers are scarce, nearest drivers collide, and greedy pickups average 158 s against the batch's 133.
- “Must no single rider wait longer than greedy matching would make them?” Yes: adds a caveat to Batch: every 2 seconds. Minimising the total can give one rider a long pickup; a cap on any single pickup, or minimising squared times, protects them.
So batching is a policy per area, not a global switch. Assignment stays fast to solve exactly, because each driver takes one rider; once a car may carry several orders in sequence, the problem becomes vehicle routing, which OR-Tools calls inherently intractable (OR-Tools). The food delivery case study in this module takes it up.
Checkpoint
Check: Greedy matching against batches every 2 seconds
Peaks concentrate in a few cells, so plans are made per area
Peaks are predictable in kind if not in size: the commutes, heavy rain, the end of a match or a concert, a holiday evening. Requests multiply in a few cells within minutes while the rest of the city stays quiet. A city shard that serves an airport and a stadium at once is split by regions of cells, and the region’s matcher scales with it. A request that finds no driver within the first ring limit searches again, a little wider each time, up to a cap, and the rider is told the truth (“looking for drivers nearby”) instead of a spinner. And every minute the matcher publishes, for each cell, the riders waiting and the drivers available; pricing reads those counts.
Under overload, Google’s SRE book describes giving every request a criticality and rejecting lower criticalities first, serving degraded responses that are cheaper to compute (Google SRE book):
| Criticality | Traffic | Under overload |
|---|---|---|
| Critical | Positions of available drivers, ride requests, offers and answers | Always served; shards and matchers sized for the peak |
| Degraded | Pickup times, history writes | Ranked by distance times a detour factor for all but the top few; history buffered |
| Sheddable | Every nearby car on the rider’s map, parked drivers’ frequent reports | Switched off per area until the area recovers |
Vehicle types and pickup points in Indian cities
Many rides in Indian cities are on two-wheelers and auto-rickshaws as well as in cars, and their speeds and the roads they may use differ. Keep the vehicle type in each index entry, search only the matching type, and compute pickup times with that type’s speed. Pickup points are often landmarks rather than street numbers, so the app snaps the pin to a nearby road where a vehicle can stop before matching starts.
Checkpoint
Check: Peaks, hot cells and shedding by criticality
What breaks at 10× and 100×
| Load | What gives way first | The change |
|---|---|---|
| 10× (1.5 million position writes a second) | The location shards of the largest cities | More shards per city by regions of cells; writes batched per gateway; reports less often from parked drivers |
| 10× | The ETA service, at about 58,000 pickup times a second | Precomputed travel times between cells refreshed from live traffic; full routes only for the final shortlist |
| 100× (15 million writes a second) | The connection gateways and the cost of encryption per report | Coarser reports when a driver is idle, positions sent only when they change cell or every few reports, more gateway machines per region |
| 100× | Matching in a few giant cities | Cities split into independent matching regions with an overlap at their borders, each with its own batch window |
| Any | A region of the cloud failing | Each city’s matching runs in one region with a standby elsewhere; driver states replicate, positions rebuild from the next reports |
Failure drills: matching pauses for seconds and never double-books
| Drill | What happens | What the design does |
|---|---|---|
| A location shard dies | Searches in that city find no drivers | The replica takes over; if both are gone, a new shard is complete after one round of reports, 4 seconds; no ride is lost |
| The driver state store fails over during an offer | The matcher does not know whether the offer committed | It retries with the same offer id; if the offer exists, the retry finds it and changes nothing |
| A matcher crashes after writing an offer | The driver waits on an offer nobody tracks | The offer expires after 12 seconds; the ride request is held under a lease that another matcher takes over |
| The ETA service slows down | Ranking stalls | Rank by straight-line distance times a detour factor per area, exact pickup times for the top three only, and flag the degradation |
| Thousands of phones reconnect after a network outage | A storm of connections | Gateways accept connections at a limited rate, phones retry with jitter, stale entries expire on their own |
| A matcher’s clock is wrong | Offers could expire early or late | Expiry is checked with the database’s clock inside the conditional update |
Checkpoint
Check: What breaks at ten and a hundred times the load · Failure drills for matching
Options considered and rejected
| Option | Why it is rejected |
|---|---|
| Rank by straight-line distance | Worse than the best driver for about one rider in eight in the run, by up to 15 minutes across a river |
| Query a database with spatial indexes for every request, written on every report | 150,000 writes a second of positions that are stale within seconds; the in-memory index is cheaper and fresher |
| A lock service around every driver | Adds a network call and a lease to every offer; the conditional update already decides who wins |
| Offer each ride to all nearby drivers at once | Shorter waits, but most drivers who accept are told the ride is gone; possible, but not the default |
| Batch matching everywhere | Adds a second of waiting where drivers are plentiful, for about 4 % shorter pickups |
| A fixed number of rings for every search | Too few rings miss faster drivers, too many waste reads; the stopping rule adapts to density |
What each level is expected to cover
- Fresher: the requirements, drivers reporting positions, a cell index, and matching the nearest driver.
- Mid-level (2 to 5 years): the estimates that find the write path, persistent connections, expiring stale positions, ranking by pickup time and the offer timeout.
- Senior (5 to 10 years): conditional offers with versions, the stopping rule for rings, sharding by city and region, the ETA service’s load, and the failure drills.
- Staff (10 years and more): batch matching and when it pays, hot cells under overload with criticality, multi-region standby, and what changes at 10× and 100×.
A 45-minute self-mock
Set a timer and answer aloud, or in writing. Spend 5 minutes on scope questions, 5 on the estimates (find the ratio of position writes to requests), 10 on the API, the data model and the first diagram, and 15 on two deep dives: the shortest pickup and the offer without double assignment are the usual pair. Use 5 minutes for batching or peaks and the last 5 for failures. Then compare your answer with this page, section by section, and with the level list above.
Practise: the whole design, one question at a time
Size the write path yourself first, then take the questions, which cover every section and begin with whichever part you know least:
Exercise · Easy · Python
Size the matching path and name its components
Write five functions in sizing.py for the estimate sheet and the request path of ride matching. Sizes are in bytes, rates per second, and a day has 86,400 seconds; 1 Mbit is 1,000,000 bits and 1 MB is 1,000,000 bytes.
location_writes_per_second(drivers_online, report_every_s): the position reports a second when every online driver reports once everyreport_every_sseconds.inbound_mbit_per_second(writes_per_second, bytes_per_report): the incoming bandwidth those reports need.index_megabytes(drivers_online, bytes_per_driver): the memory of an index that keeps one entry per online driver.ride_requests_per_second(trips_per_day, peak_factor): requests a second in the busiest hour, when that hour ispeak_factortimes the day's average rate.request_path(): the set of components a ride request passes through, in the lesson's design, from the moment the rider asks for a ride until the offer reaches the driver's phone. Choose names from this list, spelled exactly:"API gateway","connection gateways","driver states","ETA service","geo index","location service","matcher","payments","position log","trip service".
The sample tests accept each number within 10 %, as an estimate should; the set must be exact.
Starter code · sizing.py
DAY_SECONDS = 86_400
def location_writes_per_second(drivers_online, report_every_s):
"""Position reports a second."""
# Replace this line with your code.
return 0
def inbound_mbit_per_second(writes_per_second, bytes_per_report):
"""Incoming Mbit/s for the reports."""
# Replace this line with your code.
return 0
def index_megabytes(drivers_online, bytes_per_driver):
"""MB of an index with one entry per online driver."""
# Replace this line with your code.
return 0
def ride_requests_per_second(trips_per_day, peak_factor):
"""Ride requests a second in the busiest hour."""
# Replace this line with your code.
return 0
def request_path():
"""The components a ride request passes through until the offer reaches the driver."""
# Replace this line with your code.
return set() The sample tests · test_sizing.py
import math
from sizing import inbound_mbit_per_second, index_megabytes, location_writes_per_second, request_path, ride_requests_per_second
def near(actual, expected):
"""Within 10%, as an estimate should be."""
return math.isclose(actual, expected, rel_tol=0.10)
def test_location_writes():
"""one report per driver per interval"""
assert near(location_writes_per_second(600_000, 4), 150_000)
assert near(location_writes_per_second(90_000, 2), 45_000)
def test_inbound_bandwidth():
"""bytes a second times 8, in megabits"""
assert near(inbound_mbit_per_second(150_000, 120), 144)
assert near(inbound_mbit_per_second(45_000, 200), 72)
def test_index_memory():
"""one entry per online driver"""
assert near(index_megabytes(600_000, 160), 96)
assert near(index_megabytes(90_000, 150), 13.5)
def test_requests_at_peak():
"""the day's average rate times the peak factor"""
assert near(ride_requests_per_second(8_000_000, 2.5), 231.5)
assert near(ride_requests_per_second(1_000_000, 3), 34.7)
def test_request_path():
"""from the rider's request to the offer on the driver's phone"""
assert request_path() == {"API gateway", "matcher", "geo index", "ETA service", "driver states", "connection gateways"} A hint
Writes are drivers_online / report_every_s. For bandwidth, multiply by the bytes and by 8 for bits, then divide by 10**6. Requests are trips_per_day / 86_400 * peak_factor. For the set, follow the arrows of the design from the rider app: the request enters at one gateway and leaves through another, and nothing after the driver's answer belongs in it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
17 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): Why do ride-hailing systems keep driver positions in memory instead of a database? Positions change every few seconds and are useful only while they are fresh. At 600,000 drivers reporting every 4 seconds a database would take 150,000 writes a second of data that is out of date within seconds, and every write would also update a spatial index. An in-memory index per city absorbs those writes, answers searches in microseconds, and needs no durability: if a shard is lost, the next round of reports rebuilds it within one interval. What must not be lost, a driver’s state (available, offered, on a trip), lives in a durable store, and a log keeps the position history for receipts and analytics.
Warm-up (fresher to mid-level): Why match on pickup time rather than distance? The rider cares when the car arrives, and roads decide that, not straight lines. A driver across a river, on the far side of a divided highway or facing the wrong way on a one-way street can be the nearest in a straight line and still take minutes longer than a driver further away. In this lesson’s run the nearest driver in a straight line was not the fastest for about one rider in eight. Distance is still useful to find candidates cheaply; pickup times over the road network then rank them.
Interviewers often follow these answers with two sharper questions. Pick your reply to each, then read why it works or falls short:
Checkpoint
Interview round: two follow-up questions
Key takeaways
- Position writes outnumber ride requests by hundreds to one: design the write path first, in memory, sharded by city, with entries that expire when reports stop.
- Find candidates in rings of cells, rank them by pickup time over the roads, and stop reading rings when no unread cell can hold a faster driver.
- Offer a ride with a conditional update on the driver’s state and version; check expiry with the store’s clock; make accepts idempotent by offer id.
- Batch matching every couple of seconds pays where drivers are scarce, 16 % shorter pickups at 1.1 drivers per rider in the run, and hardly at all where they are plentiful.
- Plan peaks per area: split hot shards, widen searches with time, and shed the least critical traffic first.
That was a complete case study. 67 more case studies have in-depth parts that open with any pass.
References
- H3 documentation: Overview of the H3 geospatial indexing system (H3 project)
- H3 documentation: Tables of cell statistics across resolutions (H3 project)
- H3 documentation: Grid traversal functions (gridDisk, gridRing) (H3 project)
- H3 documentation: Aggregation (why hexagons) (H3 project)
- S2 Geometry (S2 Geometry project)
- RFC 6455: The WebSocket Protocol, section 5.5 (control frames: Ping and Pong) (IETF)
- GEOSEARCH command reference (Redis)
- Transactions: optimistic locking using check-and-set (Redis)
- PostgreSQL documentation: UPDATE (outputs) (The PostgreSQL Global Development Group)
- Idempotent requests (API reference) (Stripe)
- OR-Tools: Solving an assignment problem (Google for Developers)
- OR-Tools: Linear sum assignment solver (Google for Developers)
- OR-Tools: Vehicle routing (Google for Developers)
- Site Reliability Engineering, chapter 21: Handling Overload (Google (O'Reilly Media))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress