Design a Ride-Hailing Match

Index moving supply with a cell grid, offer each trip to one driver at a time under a lease, and guard every trip transition.

Syntaxh3.gridDisk(h3.latLngToCell(lat, lng, 8), 1) // h3-js v4 names; v3 spelled this kRing(geoToH3(lat, lng, 8), 1)

A ride-hailing match is three problems wearing one name: index supply that is constantly moving, choose an assignment while other riders are choosing too, and track a trip whose participants have unreliable networks. They have different failure modes, so pull them apart before drawing any boxes.

Indexing moving supply

Driver positions arrive every few seconds and only the newest one matters, so this is a keyed overwrite with a TTL rather than an append-only log. Nearby lookups need a grid: a geohash prefix, an S2 cell, or Uber's H3 hexagons. Uber chose hexagons because a hexagon's six neighbours are all the same distance away, while a square cell's diagonal neighbours are further than its edge neighbours, and geohash cells narrow in longitude as latitude rises. A query becomes "my cell plus its ring", widening the ring until there are enough candidates.

Matching under contention

Two riders must never be offered the same driver. Make the offer an exclusive lease: a conditional write on the driver key with a short TTL, held by one trip at a time, released on decline or expiry. Greedy nearest-first is the baseline; batching requests into a short window and solving the assignment across the batch lowers total pickup time at the cost of a few seconds of latency.

The trip state machine

Write the states down and reject everything else: requested, offered, accepted, arrived, in_progress, then completed or cancelled. Transitions come from phones in tunnels, so they repeat, arrive late and arrive out of order. Carry a client sequence number and guard every transition on the current state, or a stale "driver arrived" will reopen a finished trip.

Example

Example · javascript
// Supply index: newest position per driver, searchable by radius. Every update
// overwrites, because a position from eight seconds ago has no value.
import { createClient } from 'redis';
import * as h3 from 'h3-js'; // v4 API

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const RES = 8;         // H3 resolution 8: average cell area about 0.74 km2
const ONLINE_TTL = 60; // no fix for a minute means the driver is gone

export async function updateDriverLocation(driverId, lat, lng, seq) {
  const cell = h3.latLngToCell(lat, lng, RES); // v3 called this geoToH3

  const tx = redis.multi();
  // GEOADD overwrites an existing member's position - exactly what we want.
  tx.geoAdd('drivers:geo', { longitude: lng, latitude: lat, member: driverId });
  tx.hSet(`driver:${driverId}`, { cell, lat: String(lat), lng: String(lng), seq: String(seq) });
  tx.expire(`driver:${driverId}`, ONLINE_TTL);
  await tx.exec();
}

// Going offline must remove the geo member explicitly: the TTL above only
// touches the hash, and a stale member in the index is a phantom driver.
export async function setDriverOffline(driverId) {
  const tx = redis.multi();
  tx.zRem('drivers:geo', driverId); // geo sets ARE sorted sets; ZREM is the removal
  tx.del(`driver:${driverId}`);
  await tx.exec();
}

// Candidates for a pickup: widen the radius until there are enough, then stop.
// A city-centre request resolves on the first ring; a rural one does not.
export async function candidates(lat, lng, want = 10) {
  for (const radiusM of [1_500, 3_000, 8_000]) {
    // GEOSEARCH is Redis 6.2 and later. The longhand on older servers is
    // GEORADIUS drivers:geo <lng> <lat> <radius> m ASC COUNT <n>, which 6.2
    // deprecated in favour of this. Add WITHDIST (geoSearchWith) if the caller
    // needs distances rather than just ids.
    const found = await redis.geoSearch(
      'drivers:geo',
      { longitude: lng, latitude: lat },
      { radius: radiusM, unit: 'm' },
      { SORT: 'ASC', COUNT: want }
    );
    // Straight-line distance is a filter, not the ranking: a driver across the
    // river is close and 12 minutes away. Rank the shortlist by routed ETA.
    if (found.length >= want || radiusM === 8_000) return found;
  }
  return [];
}

When to use it

  • Finding the ten nearest available drivers to a pickup point in a few tens of milliseconds.
  • Making sure two riders requesting at the same second cannot both be matched to one car.
  • Handling a 'driver arrived' event that sat queued on a phone for four minutes.
  • Sharding dispatch so one busy airport does not serialise matching for the whole city.

More examples

The offer is a lease, not a notification

The lease turns a race into a queue of one. Reach for exactly this shape wherever a scarce resource is offered to a human who may not answer: dispatch, warehouse pick assignment, support ticket routing. Two details carry it: the TTL means a driver whose phone died blocks nobody, and the compare-and-delete in Lua is what makes a late decline harmless. Without the Lua check, the bug looks like drivers losing trips they just accepted.

Example · javascript
// Dispatch offers a trip to one driver at a time. The lease is the only thing
// stopping two riders from both receiving an accept from the same car.
const OFFER_TTL_S = 12; // the driver has 12 seconds to tap accept

async function tryOffer(tripId, driverId) {
  // SET key value NX EX n is the atomic claim: NX means only-if-absent, EX sets
  // the expiry in the same command. The longhand pair SETNX + EXPIRE is two
  // round trips and leaves an eternal lease if the process dies between them.
  const ok = await redis.set(`driver:lease:${driverId}`, tripId, {
    NX: true,
    EX: OFFER_TTL_S,
  });
  if (ok !== 'OK') return false; // already offered this trip or another one

  await pushToDriverApp(driverId, { type: 'offer', tripId, expiresInMs: OFFER_TTL_S * 1000 });
  return true;
}

// Only the lease holder may release it. A decline that arrives late, for a trip
// the driver was offered two trips ago, must not free the current lease.
const RELEASE = `
  if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
  else
    return 0
  end`;

async function release(driverId, tripId) {
  await redis.eval(RELEASE, {
    keys: [`driver:lease:${driverId}`],
    arguments: [tripId],
  });
}

// Walk the shortlist in ETA order. Each miss costs one offer window, which is
// why the shortlist is ranked before any offer goes out, not after.
export async function dispatch(tripId, shortlist) {
  for (const driverId of shortlist) {
    if (!(await tryOffer(tripId, driverId))) continue;
    const answer = await waitForAnswer(tripId, driverId, OFFER_TTL_S * 1000);
    if (answer === 'accepted') return driverId;
    await release(driverId, tripId); // declined, or the window lapsed
  }
  return null; // no driver: widen the radius, or tell the rider honestly
}

Trip transitions that survive a tunnel

Shows the state machine as code rather than as a diagram in a document: the transition table is the specification and the UPDATE enforces it. Reach for it for any entity whose events come from a mobile client — trips, deliveries, field jobs. The sequence number is what makes a four-minute-old queued "driver arrived" harmless, and the ignored-transition counter is how you find out that a client version is misbehaving before support does.

Example · javascript
// Every legal edge, written down once. Anything not listed here is rejected,
// which is cheaper than discovering at 02:00 that a trip went from completed
// back to in_progress.
const ALLOWED = {
  requested:   ['offered', 'cancelled'],
  offered:     ['accepted', 'requested', 'cancelled'], // back to requested on decline
  accepted:    ['arrived', 'cancelled'],
  arrived:     ['in_progress', 'cancelled'],
  in_progress: ['completed'],       // no cancel once the rider is in the car
  completed:   [],
  cancelled:   [],
};

// States a given transition is legal FROM, derived rather than written twice.
function statesLeadingTo(to) {
  return Object.entries(ALLOWED)
    .filter(([, nexts]) => nexts.includes(to))
    .map(([state]) => state);
}

// The guard lives in the WHERE clause, so concurrent events cannot interleave
// into an illegal state no matter how many dispatch workers are running.
export async function applyTransition({ tripId, to, eventSeq }) {
  const { rows } = await pool.query(
    `UPDATE trips
        SET state = $2, last_event_seq = $3, updated_at = now()
      WHERE id = $1
        AND state = ANY($4::text[])   -- legal from the CURRENT state only
        AND last_event_seq < $3       -- drops duplicates and reordered events
    RETURNING state`,
    [tripId, to, eventSeq, statesLeadingTo(to)]
  );

  if (rows.length === 0) {
    // Not an error and not retryable: the phone sent a stale or duplicate
    // event. Count it so a rising rate is visible, and return success.
    metrics.increment('trip.transition.ignored', { to });
    return null;
  }
  return rows[0].state;
}

H3 cells as both the search unit and the shard key

Shows why a hierarchical grid earns its place over a plain radius query: the same identifier answers "who is near here" at a fine resolution and "which worker owns this area" at a coarse one. Reach for the coarse-parent shard key when dispatch has grown past one process, or when one airport's contention starts affecting matching across the city. The boundary caveat in the comment is the part that gets forgotten, and it is worth writing into the design document.

Example · javascript
import * as h3 from 'h3-js'; // v4; see h3-js/legacy for the v3 names

const lat = 37.7749, lng = -122.4194;

const cell = h3.latLngToCell(lat, lng, 8); // resolution 8, average ~0.74 km2
const ring1 = h3.gridDisk(cell, 1);        // 7 cells: the centre plus 6 neighbours
const ring2 = h3.gridDisk(cell, 2);        // 19 cells, for a sparse area

// Exact area of THIS cell, since cells are not perfectly uniform:
// h3.cellArea(cell, 'km2')

// Reading older code? The v3 spellings you will meet:
//   geoToH3     -> latLngToCell
//   h3ToGeo     -> cellToLatLng
//   kRing       -> gridDisk
//   h3ToParent  -> cellToParent
// Same functions, renamed in H3 4.0.0.

// The hierarchy is what makes the cell a useful shard key. A coarse ancestor
// groups a neighbourhood, so all dispatch decisions for one area are handled by
// one worker - and contention for a driver is resolved locally rather than
// across the fleet.
export function dispatchShard(lat, lng, shardCount) {
  // Each resolution step up multiplies cell area by about 7, so two steps is
  // roughly 49x: res 8 averages 0.74 km2, res 6 averages 36 km2.
  const coarse = h3.cellToParent(h3.latLngToCell(lat, lng, 8), 6); // ~49x larger
  // Hash the cell id, do not modulo it: H3 indexes are not evenly distributed.
  let hash = 0;
  for (const ch of coarse) hash = (hash * 31 + ch.charCodeAt(0)) | 0;
  return Math.abs(hash) % shardCount;
}

// The boundary case this creates: a rider at the edge of a coarse cell has
// candidate drivers in the neighbouring shard. Either let the shard read
// (not write) its neighbours' supply, or accept slightly worse matches at
// boundaries. Pick deliberately, because the symptom - long waits on one
// street - looks like a routing bug.

Discussion

  • Be the first to comment on this lesson.