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.
- 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.
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:
| Quantity | Calculation | Result |
|---|---|---|
| Location updates | 5M online drivers ÷ a fix every 4 s | 1.25M/s |
| Ingest bandwidth | 1.25M/s × ~100 B | ≈ 125 MB/s |
| Latest positions in memory | 5M × ~200 B | ≈ 1 GB |
| A database row per update | 1.25M/s × 100 B × 86,400 s | ≈ 10.8 TB/day |
| Rides | 20M/day ÷ 86,400 s | ≈ 230/s (~700/s at peak) |
| Matching searches | ~3 per ride | ≈ 2K/s at peak |
| Riders browsing the map | 2M 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
| Direction | Message | Payload |
|---|---|---|
| Driver → server | location | { lat, lon, heading, speed, accuracy, ts } every ~4 s |
| Driver → server | status | { online: true } or { online: false } |
| Server → driver | offer | { rideId, pickup, fare, etaS, expiresAt } |
| Driver → server | offer_reply | { rideId, accept } |
| Server → rider | ride_update | { rideId, state, driver, etaS } |
| Server → rider | driver_location | { lat, lon, heading, ts } while matched |
REST for the rest:
| Method | Path | Notes |
|---|---|---|
| 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/:id | State, driver and ETA; called after every reconnect |
| POST | /rides/:id/cancel | Until 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.
| Key | Type | Purpose |
|---|---|---|
geo:{city} | GEO (a sorted set) | Online drivers by position: GEOADD per fix, GEOSEARCH to match |
loc:DRIVER | hash, EX 15 | Latest fix; expires when the app goes quiet |
drv:DRIVER | hash | state (AVAILABLE, OFFERED, ON_TRIP); compare-and-set only |
ride:RIDE | pub/sub channel | The matched driver's fixes, for the rider's gateway |
cell:{city}:GEOHASH | string, EX 3 | Nearby-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 drivers | Scan them all | Cell + 8 neighbours |
|---|---|---|
| 500 (small town) | 500 checks | About 500 |
| 100,000 (big city) | 100,000 checks | About 600 |
| 5M (one pile) | 5M checks | About 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.
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
Text description
- Every 4 s the driver app sends { lat, lon, heading, ts } over its WebSocket. 5M online drivers make 1.25M of these per second.
- The frame arrives on the gateway that holds this driver's socket.
- 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.
- 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.
- 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.
- Consumers write hourly Parquet files for analytics and ETA training. No database row was written for this update.
- This driver is on a trip, so the fix is also published on the ride's channel, ride:R42.
- The gateway holding the rider's socket subscribed to ride:R42 when the ride was matched, so it receives the fix.
- It goes down the rider's WebSocket…
- …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.
- Client
- Service
- Cache
- Database
Text description
- Rider app → Rides API: POST /rides + Idempotency-Key
- Rides API → Rides DB: INSERT ride (REQUESTED)
- Rides API replies to Rider app: 201 rideId: searching
- Rides API sends (async) to Matcher: match(R42)
- Matcher → Redis: GEOSEARCH near pickup
- Redis replies to Matcher: D7, D2, D5 + distances
- Matcher → Redis: CAS D7: AVAILABLE → OFFERED
- Redis replies to Matcher: 1 (reserved for 15 s)
- Matcher → Driver app: offer R42, 12 s to answer
- Driver app → Matcher: accept
- Matcher → Redis: CAS D7: OFFERED → ON_TRIP
- Matcher → Rides DB: UPDATE → MATCHED if REQUESTED
- Rides DB replies to Matcher: 1 row
- Matcher sends (async) to Rider app: matched: D7, ETA 3 min
Step-by-step answer
In one line each:
Read more · 762 words
Clarify
Drivers, rides, cities, freshness, offers.
Estimate
Location writes first: 1.25M a second decides most of the design.
Index locations
Latest fix in a per-city geo index; history to a log.
Match safely
Ring search, ETA ranking, one offer at a time.
Real time and failure
Sockets, a smooth marker, , regional .
In full:
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.
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();GEOADDoverwrites the old position, so a driver leaves the old cell automatically. History is batched to and nothing waits on it.Index space with cells
A cuts the map into grid cells; a shared prefix means a shared cell.
Precision Cell size at the equator 5 4.9 km × 4.9 km 6 1.2 km × 0.6 km 7 153 m × 153 m 8 38 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 WITHDISTPartition by city
Rides never cross cities, so the city is the . Huge cities split by zone.
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.
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 1The loser gets 0 and tries its next candidate.
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.
Ride lifecycle in the database
REQUESTED → MATCHED → ARRIVING → IN_TRIP → COMPLETED, orCANCELLED. 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.
Real time to the rider
Once matched, the rider's gateway listens to
ride:RIDEfor that driver's fixes. The client glides the marker to each new fix, and callsGET /rides/:idfirst after a reconnect. See polling, SSE and WebSockets.Fare estimate and surge
Every minute, per cell: demand over supply becomes a capped, smoothed multiplier. The
quoteIdlocks 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.jsnever 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 breaks | Effect | Mitigation |
|---|---|---|
| A stadium empties (hot cell) | One shard and matcher overload | Smaller cells; batch matching; surge |
| A driver's app goes quiet | Offers to a ghost | loc: expires in 15 s |
| Matcher crashes mid-offer | Driver stuck in OFFERED | Reservation TTL; another matcher takes over |
| Accept after the offer expired | Driver thinks the ride is theirs | Confirm is a compare-and-set; "offer expired" |
| A region goes down | Its cities stop matching | Geo DNS failover; rebuild the index in ~4 s |
| GPS jitter | Car jumps streets | Drop bad fixes; snap to roads; glide |
| Update rate outgrows Redis | Positions go stale | Shard by city and zone; drop old fixes |
Trade-offs
Read more · 102 words
| Choice | Gain | Cost |
|---|---|---|
| Geohash cells | Plain strings; Redis GEO built in | Must read neighbours |
| H3 hexagons | Equidistant neighbours | Needs a library; no Redis GEO |
| One offer at a time | Simple; nearest first | Each decline costs up to 15 s |
| Push to riders, not poll | Fresh within a second | A 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 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.