Full-stackHard45 min

Design Uber (Ride Matching)

Drivers report their location every few seconds. A geospatial index finds the nearest ones, and the matcher gives each ride to exactly one driver.

Say it in 30 seconds
  • 1.25M/slocation updates at peak
  • ~1 GBlatest positions for 5M drivers
  • 10 to 15 sdriver time to answer an offer
Show what to say

“Drivers send a fix every 4 seconds, 1.25M a second at peak, so I overwrite the latest position in a per-city Redis GEO index and never write a database row. To match, I search the rider's cell and its 8 neighbours, rank by ETA, and offer to one driver at a time behind a compare-and-set, so no driver gets two rides.”

Trade-offs

  • Geohash vs H3: geohash is plain strings with Redis GEO built in but needs neighbour reads; H3 has equidistant rings but needs a library.
  • One offer at a time vs batch matching: simple, nearest driver first, but greedy and each decline costs up to 15 s.
  • Push vs poll for rider updates: fresh within a second with no wasted requests, but a socket per rider.

Bring it up before they ask

Two matchers racing for the same driver: reserve the driver with a TTL'd compare-and-set before sending the offer, so the loser gets 0 and tries its next candidate.

0 of 0 sections ticked
30-second answer

Drivers report a position every 4 seconds, 1.25M a second. Overwrite the latest one in memory, never in a database row. To match, search the rider's map cell and its 8 neighbours, rank by arrival time, and offer to one driver at a time.

Functional

  • Riders see nearby cars on a map
  • Request a ride with a fare estimate (surge included)
  • Match the request to one nearby driver, who accepts or declines
  • The rider watches the driver approach live
  • Trip lifecycle: requested → matched → arriving → in trip → completed or cancelled

Non-functional

  • Driver locations fresh within a few seconds
  • A match within a few seconds of the request
  • A driver never gets two rides, and a ride never gets two drivers
  • About 1.25M location updates per second at peak
  • Regional isolation: an outage in one region doesn't stop other cities

Out of scope

  • Payments and driver payouts
  • Routing engine internals (we call an ETA service)
  • Ratings, chat and scheduled rides

Scope and scale

Read more · 206 words
  • Scale? Drivers online at peak, rides a day, cities.
  • Freshness? How old may a position be? Here, 4 s.
  • Matching? Nearest car or shortest arrival time? One request at a time, or batches?
  • Offers? How long to answer? One driver at a time, so nobody races.

Size it:

QuantityCalculationResult
Location updates5M online drivers ÷ a fix every 4 s1.25M/s
Ingest bandwidth1.25M/s × ~100 B≈ 125 MB/s
Latest positions in memory5M × ~200 B≈ 1 GB
A database row per update1.25M/s × 100 B × 86,400 s≈ 10.8 TB/day
Rides20M/day ÷ 86,400 s≈ 230/s (~700/s at peak)
Matching searches~3 per ride≈ 2K/s at peak
Riders browsing the map2M open apps ÷ a refresh every 5 s≈ 400K/s reads

For the cell numbers, take a city of roughly 1,000 km² with 100,000 drivers: about 70 per 1.2 km × 0.6 km cell, so 9 cells hold roughly 600. Matching is cheap. The location write rate drives the design. Only the latest fix matters and it fits in 1 GB, so overwrite it in memory. For map browsing, serve a per-cell snapshot cached for a few seconds.

API and data model

messages: one socket per app, JSON frames with a type field.

Read more · 365 words
DirectionMessagePayload
Driver → serverlocation{ lat, lon, heading, speed, accuracy, ts } every ~4 s
Driver → serverstatus{ online: true } or { online: false }
Server → driveroffer{ rideId, pickup, fare, etaS, expiresAt }
Driver → serveroffer_reply{ rideId, accept }
Server → riderride_update{ rideId, state, driver, etaS }
Server → riderdriver_location{ lat, lon, heading, ts } while matched

REST for the rest:

MethodPathNotes
GET/drivers/nearby?lat=&lon=Car markers for the map, from the per-cell cache
POST/fares/estimate{ pickup, dropoff } → { quoteId, fare, surge, expiresAt }
POST/rides{ quoteId, pickup, dropoff } + Idempotency-Key → 201 { rideId, state: 'REQUESTED' }
GET/rides/:idState, driver and ETA; called after every reconnect
POST/rides/:id/cancelUntil the trip starts

The turns a double-tap into one ride. Check who the user is during the HTTP upgrade, as in the chat design.

Next, the data.

Rides are durable and low-volume: Postgres, by city.

Live driver state is soft and fast, so it lives in , one key set per city.

KeyTypePurpose
geo:{city}GEO (a sorted set)Online drivers by position: GEOADD per fix, GEOSEARCH to match
loc:DRIVERhash, EX 15Latest fix; expires when the app goes quiet
drv:DRIVERhashstate (AVAILABLE, OFFERED, ON_TRIP); compare-and-set only
ride:RIDEpub/sub channelThe matched driver's fixes, for the rider's gateway
cell:{city}:GEOHASHstring, EX 3Nearby-car snapshot for the map

History goes to , keyed by driver id, then to for analytics.

CREATE TABLE rides (
  id            uuid PRIMARY KEY,
  city          text   NOT NULL,
  rider_id      bigint NOT NULL,
  driver_id     bigint,
  state         text   NOT NULL CHECK (state IN ('REQUESTED', 'MATCHED', 'ARRIVING', 'IN_TRIP', 'COMPLETED', 'CANCELLED')),
  pickup        point  NOT NULL,                 -- (lon, lat)
  dropoff       point  NOT NULL,
  quote_id      uuid   NOT NULL,
  surge         numeric(3, 2) NOT NULL DEFAULT 1.00,
  fare_cents    int,
  idem_key      text   NOT NULL UNIQUE,          -- a double tap finds the first ride
  requested_at  timestamptz NOT NULL DEFAULT now(),
  matched_at    timestamptz,
  completed_at  timestamptz
);
-- A backstop for the matcher: at most one active ride per driver, whatever happens upstream.
CREATE UNIQUE INDEX one_active_ride_per_driver ON rides (driver_id)
  WHERE state IN ('MATCHED', 'ARRIVING', 'IN_TRIP');

Architecture

A driver's position is a sticky note: write the new one over the old one, in fast memory.

Read more · 74 words

Nothing waits on a database. Do the sum on finding drivers within 2 km:

Online driversScan them allCell + 8 neighbours
500 (small town)500 checksAbout 500
100,000 (big city)100,000 checksAbout 600
5M (one pile)5M checksAbout 600

A small town can just scan. The index pays off when a search would check thousands of cars. Play Location update, Request → match, Driver declines → next and Region failure.

A cache is sticky notes on your monitor: the answers you need most, within arm's reach.

Press Play to watch it run · 10 steps

Driver apps stream a fix every 4 s into a per-city Redis geo index that keeps only the latest one; history is batched to Kafka. The matcher searches the index, ranks by ETA, and reserves a driver with a compare-and-set before offering. Rides live in Postgres; riders follow their driver on a per-ride pub/sub channel.

Ride matching architecture (one region)Driver apps stream a fix every 4 s into a per-city Redis geo index that keeps only the latest one; history is batched to Kafka. The matcher searches the index, ranks by ETA, and reserves a driver with a compare-and-set before offering. Rides live in Postgres; riders follow their driver on a per-ride pub/sub channel.resolveWebSocketWS + HTTPSRESTfixesGEOADDon a tripsubscribebatchesridesmatch(ride)offers, repliesGEOSEARCH, CASETAsMATCHEDDriver appfix every 4 sRider appmap + rideEach city is homed in one region. On a regional outage, DNS points its apps at the standby region.Geo DNScity → regionLoad balancerL4, long-livedOne channel per active ride. The gateway holding the rider's socket subscribes to it.Redis pub/subride:<id>WS gateways~140 × 50K socketsRides APIRESTDrops stale or out-of-order fixes, updates the geo index, forwards fixes for active rides and batches history. Never writes a database row.Location service1.25M fixes/sMatcherper cityThe source of truth for rides: state, driver, fare. Replicated to the standby region.Rides DBPostgres, by cityGEO set of online drivers per city, a TTL'd live record per driver, and each driver's state, changed only by compare-and-set.Redis geo indexgeo:{city}ETA serviceroad graphKafkalocation historyObject storageParquet by hour

Tap any box to see what it is and what it talks to.

  • Client
  • DNS
  • Load balancer
  • Pub/Sub
  • API gateway
  • Service
  • Database
  • Cache
  • Queue
  • Object storage
Open in playground
Text description
  1. Every 4 s the driver app sends { lat, lon, heading, ts } over its WebSocket. 5M online drivers make 1.25M of these per second.
  2. The frame arrives on the gateway that holds this driver's socket.
  3. The location service drops the fix if its timestamp isn't newer than the last one: out-of-order fixes would move the car backwards.
  4. One pipelined round trip: GEOADD geo:{sf} moves the driver (the member's score is its geohash, so it leaves the old cell automatically), and the live record's 15 s TTL is refreshed.
  5. The fix joins a batch for Kafka. Off a trip, one fix per 30 s is kept; on a trip, every fix, for the fare and any dispute.
  6. Consumers write hourly Parquet files for analytics and ETA training. No database row was written for this update.
  7. This driver is on a trip, so the fix is also published on the ride's channel, ride:R42.
  8. The gateway holding the rider's socket subscribed to ride:R42 when the ride was matched, so it receives the fix.
  9. It goes down the rider's WebSocket…
  10. …and the rider's app glides the car marker from where it is drawn to the new fix over the next 4 s, so the car moves smoothly instead of jumping.

Watch it run

Drop a rider on the map.

Read more · 111 words

The search ring grows cell by cell, and the nearest free car gets a countdown offer. A decline passes it on. Before offering, the matcher reserves the driver with a : "mark OFFERED, but only if still AVAILABLE". Press Two riders, one driver to see it break a tie.

The matcher sees only the last reported position, so the winner can be farther than it looks. Time runs fast here.

The same match as a sequence. Pick a branch: the driver accepts, declines, or never answers.

A state machine is a vending machine: it is always in one state, and each event moves it to the next.
Interactive

Ride matching on a city grid

Tap the map to call a ride, then watch the search ring grow.

MarketStadiumStation9q8d29q8d89q8db9q8e09q8e29q8e89q8eb9q8s09q86r9q86x9q86z9q87p9q87r9q87x9q87z9q8kp9q86q9q86w9q86y9q87n9q87q9q87w9q87y9q8kn9q86m9q86t9q86v9q87j9q87m9q87t9q87v9q8kj9q86k9q86s9q86u9q87h9q87k9q87s9q87u9q8kh9q8679q86e9q86g9q8759q8779q87e9q87g9q8k5D1D2D3D4D5D6D7D8D9D10D11D12D13D14
Request a ride at:
Avg pickup
–no matches yet
Avg time to match
–0 matched, request → accept
Location updates
0.0/s14 cars × 1 per 2 s
Examined per match
–of 14 (cell index)

Cars cruise and report their location every 2 s. Tap the map, or pick a pickup spot, to request a ride.

Settings and legend
Index
  • Available
  • Offered (countdown)
  • On a trip
  • Rider
  • Search ring (dashed circle: candidate)
  • Location update (every 2 s)

Press Play to watch it run · 14 steps

The ride is stored first, then the matcher finds candidates, reserves one with a compare-and-set and offers the ride. Pick a branch: the driver accepts, declines, or never answers.

Request, offer, matchThe ride is stored first, then the matcher finds candidates, reserves one with a compare-and-set and offers the ride. Pick a branch: the driver accepts, declines, or never answers.POST /rides + Idempotency-KeyINSERT ride (REQUESTED)201 rideId: searchingmatch(R42)GEOSEARCH near pickupD7, D2, D5 + distancesDrop busy and stale drivers; rank the rest by ETACAS D7: AVAILABLE → OFFERED1 (reserved for 15 s)offer R42, 12 s to answeralt [accept]acceptCAS D7: OFFERED → ON_TRIPUPDATE → MATCHED if REQUESTED1 rowmatched: D7, ETA 3 minThe rider's gateway subscribes to ride:R42 for D7's fixes
  • Client
  • Service
  • Cache
  • Database
Text description
  1. Rider app → Rides API: POST /rides + Idempotency-Key
  2. Rides API → Rides DB: INSERT ride (REQUESTED)
  3. Rides API replies to Rider app: 201 rideId: searching
  4. Rides API sends (async) to Matcher: match(R42)
  5. Matcher → Redis: GEOSEARCH near pickup
  6. Redis replies to Matcher: D7, D2, D5 + distances
  7. Matcher → Redis: CAS D7: AVAILABLE → OFFERED
  8. Redis replies to Matcher: 1 (reserved for 15 s)
  9. Matcher → Driver app: offer R42, 12 s to answer
  10. Driver app → Matcher: accept
  11. Matcher → Redis: CAS D7: OFFERED → ON_TRIP
  12. Matcher → Rides DB: UPDATE → MATCHED if REQUESTED
  13. Rides DB replies to Matcher: 1 row
  14. Matcher sends (async) to Rider app: matched: D7, ETA 3 min

Step-by-step answer

In one line each:

Read more · 762 words
  1. Clarify

    Drivers, rides, cities, freshness, offers.

  2. Estimate

    Location writes first: 1.25M a second decides most of the design.

  3. Index locations

    Latest fix in a per-city geo index; history to a log.

  4. Match safely

    Ring search, ETA ranking, one offer at a time.

  5. Real time and failure

    Sockets, a smooth marker, , regional .

In full:

  1. State the two hard parts

    "Locations are a firehose of overwrites. Assignments must happen exactly once." The first rules out a database write per update. The second rules out check-then-write while matchers race.

  2. Ingest locations into memory

    A fix every 4 s over a . Drop out-of-order fixes. One pipelined round trip updates :

    await redis.multi()
      .geoAdd(`geo:{${city}}`, { longitude: lon, latitude: lat, member: id })
      .hSet(`loc:${id}`, { lat, lon, heading, ts })
      .expire(`loc:${id}`, 15)
      .exec();

    GEOADD overwrites the old position, so a driver leaves the old cell automatically. History is batched to and nothing waits on it.

  3. Index space with cells

    A cuts the map into grid cells; a shared prefix means a shared cell.

    PrecisionCell size at the equator
    54.9 km × 4.9 km
    61.2 km × 0.6 km
    7153 m × 153 m
    838 m × 19 m

    Cells have edges: a rider near one is closer to the next cell than to most of their own. So read the 8 neighbours too. Redis does:

    GEOSEARCH geo:{sf} FROMLONLAT -122.41 37.7749 BYRADIUS 2 km ASC COUNT 20 WITHDIST
  4. Partition by city

    Rides never cross cities, so the city is the . Huge cities split by zone.

  5. Find and rank candidates

    Search ring by ring: the rider's cell, then the 8 around it, then the 16 around those. Stop once the best are nearer than the next ring. Skip busy drivers and expired records, then rank the best 10 by ETA. Across a river, the closest car is often not the quickest.

  6. Offer behind a compare-and-set

    Driver state runs AVAILABLE → OFFERED → ON_TRIP. Reserve before offering, so two matchers can't both send an offer:

    -- KEYS[1] = drv:<id>; ARGV: expected state, expected ride ('' = any), new state, new ride, reservation ms ('' = none)
    local cur = redis.call('HMGET', KEYS[1], 'state', 'rideId', 'offerUntil')
    local t = redis.call('TIME')
    local now = t[1] * 1000 + math.floor(t[2] / 1000)
    local state = cur[1]
    if state == 'OFFERED' and tonumber(cur[3] or 0) < now then state = 'AVAILABLE' end  -- an expired reservation is free
    if state ~= ARGV[1] or (ARGV[2] ~= '' and cur[2] ~= ARGV[2]) then return 0 end
    redis.call('HSET', KEYS[1], 'state', ARGV[3], 'rideId', ARGV[4], 'offerUntil', ARGV[5] == '' and 0 or now + tonumber(ARGV[5]))
    return 1

    The loser gets 0 and tries its next candidate.

  7. Declines, timeouts and crashes

    The driver has 10–15 s to answer, and silence is a decline. The next candidate gets the offer. If none is left, widen the search. A crashed matcher's reservation simply expires.

  8. Ride lifecycle in the database

    REQUESTED → MATCHED → ARRIVING → IN_TRIP → COMPLETED, or CANCELLED. A conditional update means a ride can't get two drivers:

    UPDATE rides SET state = 'MATCHED', driver_id = $2, matched_at = now()
    WHERE id = $1 AND state = 'REQUESTED';

    The unique index on active rides is the last backstop.

  9. Real time to the rider

    Once matched, the rider's gateway listens to ride:RIDE for that driver's fixes. The client glides the marker to each new fix, and calls GET /rides/:id first after a reconnect. See polling, SSE and WebSockets.

  10. Fare estimate and surge

    Every minute, per cell: demand over supply becomes a capped, smoothed multiplier. The quoteId locks the fare for a few minutes.

Implementation

Server tabs: , ring search, matcher, ingest.

Read more · 124 words

match.test.js races two matchers for the same drivers.

Client tabs: the rider's map hook and its tests.

  • nearby() is tested against brute force on 20 random layouts.
  • matchRide() holds the reservation 3 s past the offer, so a last-second accept lands.
  • location-ingest.js never awaits . Only the 15 s takes a driver offline, so a tunnel doesn't end a shift.
  • planGlide() glides from the drawn position, so the car never jumps.
// Geohash: split the world in half again and again, alternating longitude and latitude,
// and write the bits 5 at a time in base 32. Each extra character makes the cell 32× smaller,
// and points that share a prefix are in the same cell.
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';   // no a, i, l, o
const EARTH_RADIUS_M = 6_371_000;

export function encode(lat, lon, precision = 6) {
  let latLo = -90, latHi = 90, lonLo = -180, lonHi = 180;
  let hash = '', ch = 0, bits = 0, lonBit = true;          // the first bit is longitude
  while (hash.length < precision) {
    if (lonBit) {
      const mid = (lonLo + lonHi) / 2;
      if (lon >= mid) { ch = ch * 2 + 1; lonLo = mid; } else { ch *= 2; lonHi = mid; }
    } else {
      const mid = (latLo + latHi) / 2;
      if (lat >= mid) { ch = ch * 2 + 1; latLo = mid; } else { ch *= 2; latHi = mid; }
    }
    lonBit = !lonBit;
    if (++bits === 5) { hash += BASE32[ch]; ch = 0; bits = 0; }
  }
  return hash;
}

// The cell's bounding box: replay the same halvings.
export function bounds(hash) {
  let latLo = -90, latHi = 90, lonLo = -180, lonHi = 180, lonBit = true;
  for (const c of hash) {
    const v = BASE32.indexOf(c);
    if (v < 0) throw new Error(`Not a geohash character: ${c}`);
    for (let b = 4; b >= 0; b--) {
      const bit = (v >> b) & 1;
      if (lonBit) { const mid = (lonLo + lonHi) / 2; if (bit) lonLo = mid; else lonHi = mid; }
      else { const mid = (latLo + latHi) / 2; if (bit) latLo = mid; else latHi = mid; }
      lonBit = !lonBit;
    }
  }
  return { latLo, latHi, lonLo, lonHi };
}

// The cell dRow rows north and dCol columns east of this one, at the same precision.
// Cells of one precision form a regular grid, so stepping from the centre lands in the middle of the target.
export function offset(hash, dRow, dCol) {
  const b = bounds(hash);
  const lat = (b.latLo + b.latHi) / 2 + dRow * (b.latHi - b.latLo);
  if (lat > 90 || lat < -90) return null;                    // past a pole
  const lon = (b.lonLo + b.lonHi) / 2 + dCol * (b.lonHi - b.lonLo);
  return encode(lat, ((lon + 540) % 360) - 180, hash.length); // wrap across the antimeridian
}

// The 8 cells around this one. A query must look at them too: a driver 10 m away can sit across an edge.
export function neighbours(hash) {
  return {
    n: offset(hash, 1, 0), ne: offset(hash, 1, 1), e: offset(hash, 0, 1), se: offset(hash, -1, 1),
    s: offset(hash, -1, 0), sw: offset(hash, -1, -1), w: offset(hash, 0, -1), nw: offset(hash, 1, -1),
  };
}

// Great-circle distance in metres.
export function haversineM(a, b) {
  const rad = d => (d * Math.PI) / 180;
  const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
  return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
}

// Cell height and width in metres. Width shrinks with cos(latitude): cells get narrow near the poles.
export function cellSizeM(hash) {
  const b = bounds(hash);
  const lat = (b.latLo + b.latHi) / 2, lon = (b.lonLo + b.lonHi) / 2;
  return {
    heightM: haversineM({ lat: b.latLo, lon }, { lat: b.latHi, lon }),
    widthM: haversineM({ lat, lon: b.lonLo }, { lat, lon: b.lonHi }),
  };
}
// Rider's map: follow the matched driver. Fixes arrive every ~4 s; drawing them as they come makes the
// car jump. Instead, glide from where the marker is drawn now to each new fix over one update interval.
import { useEffect, useRef, useState } from 'react';
import { haversineM } from './geohash.js';

// Pure: plan the next glide when a fix arrives. `prev` is the current glide (or null before the first fix).
export function planGlide(prev, fix, now, { gapMs = 4_000, snapM = 500 } = {}) {
  if (prev && !(fix.ts > prev.to.ts)) return prev;            // duplicate or out of order: ignore
  const to = { lat: fix.lat, lon: fix.lon, heading: fix.heading ?? prev?.to.heading ?? 0, ts: fix.ts };
  if (!prev) return { from: to, to, start: now, duration: 0 };  // first fix: just place the car
  const from = markerAt(prev, now);                           // start from what the rider sees: no jump
  if (haversineM(from, to) > snapM) return { from: to, to, start: now, duration: 0 };   // after a long gap, teleport rather than slide through buildings
  return { from, to, start: now, duration: gapMs };
}

// Pure: where to draw the marker at time `now`.
export function markerAt(glide, now) {
  const t = glide.duration > 0 ? Math.min(1, Math.max(0, (now - glide.start) / glide.duration)) : 1;
  return {
    lat: glide.from.lat + (glide.to.lat - glide.from.lat) * t,
    lon: glide.from.lon + (glide.to.lon - glide.from.lon) * t,
    heading: lerpHeading(glide.from.heading, glide.to.heading, t),
  };
}

// Turn the short way round: from 350° to 10° is +20°, not −340°.
export function lerpHeading(a, b, t) {
  const d = ((((b - a) % 360) + 540) % 360) - 180;
  return (((a + d * t) % 360) + 360) % 360;
}

export function useDriverMarker(rideId, wsUrl) {
  const [marker, setMarker] = useState(null);                  // { lat, lon, heading }, or null before the first fix
  const glide = useRef(null);

  useEffect(() => {
    let ws, raf = 0, retry, attempt = 0, closed = false;
    const frame = now => {
      const g = glide.current;
      // Re-render only while a glide is running. With a map SDK, call marker.setPosition() here instead.
      if (g && now <= g.start + g.duration + 100) setMarker(markerAt(g, now));
      raf = requestAnimationFrame(frame);
    };
    const connect = () => {
      ws = new WebSocket(`${wsUrl}?ride=${encodeURIComponent(rideId)}`);
      ws.onopen = () => { attempt = 0; };                      // the server sends the latest fix first
      ws.onmessage = e => {
        const msg = JSON.parse(e.data);
        if (msg.type === 'driver_location') glide.current = planGlide(glide.current, msg, performance.now());
      };
      ws.onclose = () => {
        if (!closed) retry = setTimeout(connect, Math.random() * Math.min(30_000, 1_000 * 2 ** attempt++));   // full jitter
      };
    };
    connect();
    raf = requestAnimationFrame(frame);
    return () => {
      closed = true;
      clearTimeout(retry);
      cancelAnimationFrame(raf);
      ws?.close();
    };
  }, [rideId, wsUrl]);

  return marker;
}

Failure modes

Read more · 107 words
What breaksEffectMitigation
A stadium empties (hot cell)One shard and matcher overloadSmaller cells; batch matching; surge
A driver's app goes quietOffers to a ghostloc: expires in 15 s
Matcher crashes mid-offerDriver stuck in OFFEREDReservation TTL; another matcher takes over
Accept after the offer expiredDriver thinks the ride is theirsConfirm is a compare-and-set; "offer expired"
A region goes downIts cities stop matchingGeo DNS failover; rebuild the index in ~4 s
GPS jitterCar jumps streetsDrop bad fixes; snap to roads; glide
Update rate outgrows RedisPositions go staleShard by city and zone; drop old fixes

Trade-offs

Read more · 102 words
ChoiceGainCost
Geohash cellsPlain strings; Redis GEO built inMust read neighbours
H3 hexagonsEquidistant neighboursNeeds a library; no Redis GEO
One offer at a timeSimple; nearest firstEach decline costs up to 15 s
Push to riders, not pollFresh within a secondA socket per rider

Follow-up questions

How does surge pricing work?

Demand over supply per cell, mapped to a capped multiplier and smoothed so it doesn't flap. It also tells drivers where to go.

How would pooled rides change matching?

Candidates are cars with free seats, scored by the detour a new rider adds. Batch requests for a few seconds to pair riders.

What does the ETA service do?

It holds the road map with live speeds and answers shortest-route questions in milliseconds. It takes one pickup and ten drivers in one call.

How do you scale to 100 cities?

Each city is a in a nearby region. Giant cities split into zones. Accounts and payments stay global, off the hot path (sharding strategies).

How do you guarantee a driver is never offered two rides?

One authority per driver, changed only by , with expiring reservations. The unique index on active rides is the last defence.

Check yourself

Question 1 of 3

Score 2 out of 3 or better to mark this page complete.

1. 5M online drivers send a location every 4 s. Where should each update go?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.