Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

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

  1. One question Which of these may the matching system lose without harm?

    Choose one answer.

    Show the answer: One question

    Answer: A driver's latest position

    Positions are fresh for seconds and rebuilt by the next round of reports; driver states, offers and matches are decisions and live in a durable store.

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 JavaScript · location-ingest/location_ingest.mjs
// 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

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

  1. Question 1 of 2 600,000 drivers online each report a position every 4 seconds. How many position writes a second is that?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 150000 writes a second (anything from 135000 to 165000 counts)

    600,000 ÷ 4 = 150,000 writes a second, against 231 ride requests: about 650 writes for every request, which is why the write path is designed first.

  2. Question 2 of 2 Each online driver's entry in the geo index takes about 160 bytes. How many megabytes hold all 600,000?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 96 MB (anything from 86.4 to 105.6 counts)

    600,000 × 160 bytes = 96 MB, so one machine could hold the whole country. Sharding by city is for isolation and for spreading writes, not for size.

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/stream is 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 sends position messages (latitude, longitude, heading, speed, accuracy and the time of the fix), status changes and answers to offers (accept or decline with the offer id); the server sends offer messages and offer-cancelled.
  • POST /v1/rides takes the pickup point, the destination, the vehicle type and an Idempotency-Key header, and answers 202 Accepted with a ride id; GET /v1/rides/{rideId} returns its state, and POST /v1/rides/{rideId}/cancel cancels 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.

WebSocket & SSE Tester Open a WebSocket, send messages and watch the replies, to see how a persistent connection behaves.

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

  1. One question A rider's phone retries POST /v1/rides after a timeout. What keeps it from booking a second ride?

    Choose one answer.

    Show the answer: One question

    Answer: The Idempotency-Key header, under which the server saved the first request's result

    The ride store keeps (rider, idempotency key) unique, so a retry finds the ride it already made.

Positions flow into memory, offers through a durable store

Ride matching: driver and rider apps, connection and API gateways, location service, geo index, matcher, ETA service and driver states.Driver appposition every 4 sConnection gatewaysWebSocketsLocation serviceshards by cityGeo indexin memoryPosition loghistoryRider appAPI gatewayMatcherper cityETA serviceroadsDriver statesconditional updatesTrip servicepositionslatestbatchesrequestrings of cellsofferpush tothe driveraccepted

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.

  1. 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.
  2. The request path: the rider app sends a ride request through the API gateway to the matcher of the rider's city.
  3. The matcher reads candidate drivers from the geo index in rings of cells and asks the ETA service for pickup times over the roads.
  4. 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:

Interactive · step by step One ride through the high-level design Step through a driver's position report, then one ride request from the rider's tap to the driver's answer.
One ride through the high-level design, step 8 of 8. Showing Driver's phone, Connection gateways, Location service, Geo index (D's new position), Rider's app, Matcher, ETA service, Driver state store (D: on the way).requestread cellsconditional offerpush offerDriver's phoneConnection gatewayshold the WebSocketsLocation serviceone per cityGeo indexD's new positionRider's appMatcherone cityETA servicepickup times by roadDriver state storeD: on the way

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. A position. Every 4 seconds the driver's phone sends its position over the WebSocket that a connection gateway holds open.
  2. 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.
  3. A request. A rider asks for a car. The request reaches the matcher of the rider's city through the API gateway.
  4. Candidates. The matcher reads the drivers in the cells around the pickup point from the geo index.
  5. Pickup times. It asks the ETA service for their pickup times over the road network, and picks the driver who arrives first.
  6. The offer. The offer is a conditional update in the driver state store, which succeeds only if the driver is still available.
  7. 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.
  8. 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:

A driver goes from offline to available, to offered one ride by a conditional update, to on the way, on a trip and back to available.OfflineAvailablein the geo indexOffered one rideuntil a timeoutOn the wayto the riderOn a tripgoes onlineconditional update:state and version matchdeclined, timed outor the rider cancelledaccepted whilethe offer is liverider picked uptrip endedgoes offline orstops reporting

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.

  1. An offline driver becomes available when the driver goes online; an available driver is in the geo index.
  2. 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.
  3. An offered driver becomes available again when the driver declines, when the offer times out or when the rider cancels.
  4. 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.
  5. 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

  1. One question In the high-level design, where is an offer written, and how?

    Choose one answer.

    Show the answer: One question

    Answer: In the driver state store, as a conditional update that succeeds only if the driver is still available

    Positions flow into memory; decisions about drivers flow through the durable driver state store.

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:

Interactive · message by message One driver's reports, and the day the phone goes quiet Step through the reports of one driver, from a keepalive ping to an entry that expires by itself.
One driver's reports, and the day the phone goes quiet, step 6 of 6. Messages so far: Gateway to Phone: Ping; Phone to Location service: position, same cell; Phone to Location service: position, new cell.PhoneGatewayLocation serviceskipped: 15 s oldPingposition, same cellposition, new cell

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. History, in batches. Every report also goes to a log in batches, for receipts, disputes and analytics, never in the way of the index.
  5. The phone goes quiet. The driver enters a tunnel and reports stop. Nothing is sent to say so.
  6. 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”.

GPS Coordinate Converter (Lat/Long, DMS, UTM, MGRS) Convert a real position to a geohash and other formats, to see which cell a place falls in.

Checkpoint

Check: Ingesting positions into cells, sharded by city

  1. Question 1 of 2 At 6 m/s with a report every 4 seconds, 8.7 % of reports move a driver to a new cell. About how many reports are there for each change of cell?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 11.5 reports (anything from 11 to 12 counts)

    1 ÷ 0.087 ≈ 11.5: most reports overwrite a position in place, and only about one in eleven moves the driver from one cell's set to another's.

  2. Question 2 of 2 A driver's phone loses its signal in a tunnel and sends nothing. What happens to the driver's entry?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: After three missed reports, about 15 seconds, searches skip it, and it is removed later

    Expiring stale entries by age means no message is needed, and the gateway's pings close the dead connection.

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:

Four ways to find the driver with the shortest pickup Python · candidates/candidates.py
"""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

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:

Interactive · stage by stage When the matcher may stop reading rings Step through one search and see when the rule says no unread cell can hold a faster driver.
When the matcher may stop reading rings, step 5 of 5. Showing Read ring k (ring 1: 7 cells), Pickup times (best: 30 s, so 180 m), Far enough? (500 m, then 800 m), Stop. In focus: Far enough?.next ringRead ring kring 1: 7 cellsPickup timesbest: 30 s, so 180 mFar enough?500 m, then 800 mStopthe best driver is certain

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. Ring 1. Ring 1 adds the 6 neighbours, 7 cells in all. Every point within 200 m of the rider has now been read.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Cells are hexagons with 200 m edges, and after rings 0 to k every point within 200 × (1.5k − 0.5) metres of the rider has been read. How far is covered after ring 2?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 500 m

    200 × (1.5 × 2 − 0.5) = 200 × 2.5 = 500 m. If the best pickup so far is 80 s at 6 m/s, a faster driver would have to be within 480 m, so the matcher can stop.

  2. Question 2 of 2 Why is the nearest driver in a straight line often not the first to arrive?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Roads decide the pickup time, and a river, a highway or a one-way street can make the nearest driver slow

    Distance finds candidates cheaply; pickup times over the road network rank them.

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:

One program · 14 languages Two matchers, one driver, plain and 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

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, plain writes against conditional writes Python · offers/offers.py
"""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

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:

  1. One live offer per driver. The matcher never offers a driver a second ride while one is waiting.
  2. 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.
  3. Answers are idempotent. An accept retried after a lost reply finds the offer already accepted and returns the same result.
  4. 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

  1. Question 1 of 2 Matchers A and B both read driver D at version 7. A's conditional offer writes version 8. What happens to B's conditional offer for D?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It changes nothing, because D is at version 8, and B offers its rider the next candidate, E

    With plain writes both riders were promised D; with conditional writes the riders got D and E, as the block prints.

  2. Question 2 of 2 A matcher's clock runs 20 seconds fast. Why can it not expire an offer early or keep a driver locked?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Expiry is checked inside the conditional update with the database's clock, not the matcher's

    Putting the time check where the decision is made, in one statement on one clock, removes a whole class of races.

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:

Greedy matching against a batch every 2 seconds Python · batch-match/batch_match.py
"""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

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:

Interactive · answer and compare Match each request at once, or batch every 2 seconds? Answer two questions about an area. The table marks the policy that fits and says why.
How many idle drivers are there for each waiting rider in the area?
Must no single rider wait longer than greedy matching would make them?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Two matching policies against what the run measured in a busy district
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

  1. Question 1 of 2 In which area does batching requests every 2 seconds pay most?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Where drivers are scarce, about one per waiting rider

    Areas with many idle drivers match each request at once; areas where requests outnumber drivers batch.

  2. Question 2 of 2 With plentiful drivers the batch's worst pickup was 343 seconds against greedy's 300. How do you protect that rider?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Cap any single pickup in the batch, or minimise the sum of squared pickup times

    Minimising the total can sacrifice one rider; the objective decides who pays.

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

  1. Question 1 of 2 Under overload at a peak, what is shed first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Showing every nearby car on the rider's map, and parked drivers' frequent reports

    Each request carries a criticality, the least critical is rejected first, and cheaper degraded answers, such as ranking by distance times a detour factor, stand in where possible.

  2. Question 2 of 2 At the end of a concert, a request finds no driver within the first ring limit. What does the design do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Searches again, a little wider each time up to a cap, and tells the rider it is looking for drivers nearby

    Peaks are planned per area, and honest waiting is part of the design.

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

  1. Question 1 of 2 At the peak the ETA service answers about 5,787 pickup times a second. At ten times the load, about how many is that?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 57870 pickup times a second (anything from 52070 to 63670 counts)

    About 58,000 a second, so precomputed travel times between cells, refreshed from live traffic, replace full routes for all but the final shortlist.

  2. Question 2 of 2 A matcher crashes right after writing an offer. What happens to the driver and the rider?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The offer expires after 12 seconds, and another matcher takes over the ride request when its lease runs out

    Every piece of state outlives the process that wrote it, so a crash costs seconds, never a double booking.

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 every report_every_s seconds.
  • 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 is peak_factor times 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.

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.

  1. Question 1 of 17 In this case study, what does "the best driver" for a request mean?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: The shortest pickup time over the roads, among drivers with the vehicle type the rider chose

    Distance finds candidates; pickup time over the roads chooses among them.

  2. Question 2 of 17 150,000 position writes a second meet 231 ride requests a second. About how many writes are there per request?

    Type a number.

    Show the answer: Question 2 of 17

    Answer: 649 writes (anything from 584 to 714 counts)

    150,000 ÷ 231 ≈ 650 position writes for every ride request.

  3. Question 3 of 17 The team wants fresher positions and proposes reports every 2 seconds instead of 4. What does it cost and buy?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Twice the writes, 300,000 a second, for positions at most 12 m old instead of 24

    Every 8 seconds would halve the writes, with cars moving up to 48 m between reports.

  4. Question 4 of 17 A driver's accept is sent twice for the same offer, because the first reply was lost. What happens?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: The second finds the offer already accepted and gets the same answer

    Idempotent answers make retries over a flaky network harmless.

  5. Question 5 of 17 Why does every driver state carry a version number?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: Each change checks the version it read and writes the next, so two changes based on the same read cannot both succeed

    That is what makes offers safe when three matchers read the same driver.

  6. Question 6 of 17 Where does the matcher get the pickup times it ranks candidates by?

    Choose one answer.

    Show the answer: Question 6 of 17

    Answer: From the ETA service, over the road network with live speeds

    About 25 pickup times per request at the peak make the ETA service a load to plan for.

  7. Question 7 of 17 The largest city has 90,000 drivers online, each entry about 160 bytes. How many megabytes is its shard?

    Type a number.

    Show the answer: Question 7 of 17

    Answer: 14.4 MB (anything from 13 to 15.8 counts)

    90,000 × 160 bytes = 14.4 MB, easily held in memory, with a replica for fast failover.

  8. Question 8 of 17 What does adding COUNT n ANY to a Redis GEOSEARCH change?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: It returns as soon as it has found n matches, which is faster but may miss closer ones

    Either way, the search answers who is near, and the matcher still has to answer who arrives first.

  9. Question 9 of 17 In the run's quiet night, what percentage of riders had no driver at all in their own cell?

    Type a number.

    Show the answer: Question 9 of 17

    Answer: 72 % (anything from 71 to 73 counts)

    72 % at night and 18.9 % at the evening peak, which is why a search must read beyond the rider's cell.

  10. Question 10 of 17 A disk of k rings of hexagons holds 3k(k + 1) + 1 cells. How many is that for 3 rings?

    Type a number.

    Show the answer: Question 10 of 17

    Answer: 37 cells

    3 × 3 × 4 + 1 = 37; one ring holds 7 cells and two hold 19.

  11. Question 11 of 17 In the two-minute run with plain writes, how many rides were confirmed to a driver who was already busy?

    Type a number.

    Show the answer: Question 11 of 17

    Answer: 12 rides

    12, while conditional writes confirmed none and refused 35 writes that were retried with the next candidate, at almost no cost: 8.4 seconds to a driver against 8.3.

  12. Question 12 of 17 One driver in ten never answers an offer. About what does a 12-second timeout cost the average rider?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: About 1.2 seconds

    Drivers who are driving need time to read an offer safely, so the timeout is a trade.

  13. Question 13 of 17 At 1.1 drivers per rider, batching cut the mean pickup from 158 to 133 seconds. By what percentage?

    Type a number.

    Show the answer: Question 13 of 17

    Answer: 16 % (anything from 15 to 17 counts)

    25 ÷ 158 ≈ 16 %; the 90th percentile fell from 327 to 252 seconds.

  14. Question 14 of 17 What kind of problem is matching one batch of riders to drivers, each driver taking one rider?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: The assignment problem, each worker one task at most and the total cost as low as possible

    It stays fast to solve exactly because each driver takes one rider.

  15. Question 15 of 17 A stadium event ends and 30,000 people open the app within ten minutes, inside four cells, with 400 drivers nearby. What do you work out first?

    Choose one answer.

    Show the answer: Question 15 of 17

    Answer: Demand against supply, 75 riders per driver, so most riders cannot get a car within minutes whatever the design does

    Compare demand with supply before any design move.

  16. Question 16 of 17 A city's location shard and its replica are both lost. How long until matching works there again?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: About 4 seconds, one round of reports

    A replica is for fast failover, not durability.

  17. Question 17 of 17 At ten times today's load, how many position writes a second does the country send?

    Type a number.

    Show the answer: Question 17 of 17

    Answer: 1500000 writes a second (anything from 1350000 to 1650000 counts)

    10 × 150,000 = 1.5 million, which first strains the location shards of the largest cities.

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

  1. Question 1 of 2 "Would a distributed lock per driver be a better fix than the conditional update?"

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No: it adds a network call and a lease to every offer, and the conditional update already decides who wins

    A good answer names what the lock costs, a call, a lease and a failure mode, and shows that the write already has the one check that matters.

  2. Question 2 of 2 "Re-ranking the 10 nearest drivers found the best one for every rider in your run. Why not stop there?"

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing guarantees it; a long river or a highway with few exits can push the best driver out of the ten, and the stopping rule is exact

    In production the two ideas combine, a shortlist from the rings and the stopping rule for when to read more.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.