Systems

Designing Uber: how to match riders, drivers, and live location in real time

Follow Alice, Maya, and ride_42 through fare estimation, live driver location, matching leases, trip events, and failure recovery.

SSScott SantinhoAugust 09, 202624 min read
Pixel-dithered nighttime city grid with amber pickup route, used as the Uber system-design article cover.

The pickup that exposes the invariant

At 18:04:12, Alice asks for a ride from 14th Street and 8th Avenue to JFK Terminal 4. The app quotes $48.20 and shows three nearby cars. Maya (d-184) is closest; Leo (d-207) is the fallback. Alice taps Request, Maya's phone is briefly offline, and two matching workers wake up at the same time. The question is not whether every screen updates instantly. It is whether the system can keep one ride request from being assigned twice while still finding a driver quickly.

We will follow ride_42 through quote, location, matching, offer, pickup, trip, and completion. The design makes one separation explicit: location and notification paths are fast, replaceable read/transport layers; PostgreSQL owns the durable ride state. A Redis lease prevents ordinary offer races, but it is never treated as proof that a driver is permanently assigned.

The chosen architecture

The rider app enters through an API gateway. A ride service creates a server-owned fare snapshot and a requested ride. Drivers publish adaptive GPS updates to a location-ingest service, which writes a regional Redis geospatial index and a last-seen timestamp. A matcher consumes a regional request queue, searches nearby candidates, filters stale or busy drivers, and ranks the rest by ETA and policy. It acquires a token-owned ten-second lease before sending Maya an offer.

The offer is a durable multi-step workflow, not a fire-and-forget push. It waits for accept, decline, or timeout, then conditionally changes both ride and driver rows in PostgreSQL. The outbox publishes the committed transition to rider/driver streams, push delivery, billing, analytics, and trip history. If a client reconnects, it asks for events after its last sequence; it does not infer the current state from a stale notification.

This composition keeps a clear contract for every subsystem: the quote service calculates a price from server-side inputs, the location index finds candidates, the matcher owns ranking and offer arbitration, the workflow owns waiting, the database owns assignment and lifecycle state, and the outbox owns fan-out. A component may be slow or unavailable without silently becoming a second authority.

1. Start with the request, not the infrastructure

The user-facing requirements are small enough to state precisely. Alice needs a fare estimate, she needs to confirm a ride, the system needs to find a nearby available driver, and the driver needs to accept or decline while both apps see the trip move. The non-functional requirements tell us where to spend depth: matching should finish quickly or fail clearly, a driver must not receive two outstanding offers, and a peak event must not drop requests just because one matcher process restarted.

  • POST /fare-estimates accepts pickup and destination; the server owns the route, price inputs, currency, and expiry.

  • POST /rides accepts a fare ID plus an idempotency key; it creates ride_42 in requested state.

  • POST /drivers/location derives driver identity and server time from the authenticated session; the body cannot impersonate another driver.

  • PATCH /ride-offers/:offerId accepts or declines one offer token; the database checks the token and current state.

The first endpoint creates a quote snapshot, not a promise that the eventual trip will cost exactly the same amount. The client never sends back a fare amount and asks the server to trust it. When Alice confirms, the ride service loads the stored quote, checks it is still valid, and records the quote ID on the ride. That gives billing a reproducible input even if the pricing model changes later.

The entities and lifecycle

Keep the first model small: Rider, Driver, FareQuote, Ride, DriverLocation, Offer, and RideEvent. A driver location is a timestamped observation; an offer is a bounded attempt to reserve a driver's attention; a ride is the durable aggregate. The state machine is requested → matching → offered → accepted → en_route_pickup → arrived → in_trip → completed with explicit cancelled exits. A command that arrives twice should return the already-committed result, not move the ride forward again.

The state belongs to the database

Redis can say “Maya is currently leased to offer offer_7,” and a WebSocket can say “the driver is 400 meters away,” but neither is a durable assignment. The conditional SQL transition checks that the offer is still open, the driver is still available, and the ride is still waiting. If two accepts race, one transaction returns a row and the other returns zero rows; the losing request re-reads the committed ride and gets a stable answer.

Fare cacheQuote preview for the same routeNo: load the stored quote snapshotRecompute when expired
Redis geo indexFresh-ish nearby driver candidatesNo: filter and assign in PostgreSQLRebuild from the next heartbeat
Offer leaseOne matcher owns a ten-second attemptNo: lease only reduces contentionToken release or TTL expiry
PostgreSQL ride rowCurrent committed lifecycle stateYes: assignment and transitionsRetry idempotent commands and outbox
The state each layer may describe, and the state it is forbidden to decide.

2. Live location is a write-shaping problem

A naive design writes every GPS ping to the primary database and asks it to calculate distances across every online driver. That couples a high-frequency, replaceable signal to durable transactional storage and turns a proximity query into a scan. It also retains more precision than the matching decision needs. The reader-facing promise is “find a nearby eligible driver,” not “record every accelerometer sample forever.”

Instead, the driver app adapts its interval: slower when stopped, faster near a pickup or during a sharp turn. Location ingest validates the coordinate range, derives driverId and server time from the session, then updates one geospatial member and one last-seen score in a regional shard. The hot index is intentionally ephemeral. A driver is eligible only when now - lastSeen < 30s; otherwise the matcher treats the member as offline even if the geospatial key has not been swept yet.

Redis's Open source: GEOADD command stores longitude, latitude, and a member in a geospatial sorted set. Open source: GEOSEARCH can then return nearby members with distance and coordinates. That is a good fit for a replaceable candidate index, not for a trip's audit trail. A sampled stream writes route history asynchronously so the ride can be replayed without making every matcher lookup pay the durability cost.

If the team needs a queryable durable fallback, a PostGIS geography column plus Open source: ST_DWithin can use a spatial index to filter rows inside a radius. It is slower and more expensive for the hottest heartbeat path, but it gives operators a persistent query surface. The important design choice is the boundary: a durable fallback may find candidates, but it must still pass through the same assignment transaction.

Voxel diagram showing driver geocells flowing into a Redis geo index and a rider radius query.
Location is a hot, replaceable read model: publish the newest point, query a nearby cell, and discard stale drivers.
The hot path replaces a driver's previous point; the matcher adds a freshness check before ranking.

Redis

GEOADD geo:nyc:cell-37.78:-122.41 -122.4098 37.7765 d-184
ZADD last_seen:nyc 1764612252 d-184

GEOSEARCH geo:nyc:cell-37.78:-122.41
  FROMLONLAT -122.4101 37.7752
  BYRADIUS 3 KM ASC COUNT 40 WITHDIST WITHCOORD

TYPESCRIPT

const candidates = await geo.search(cellKey, pickup, { radiusKm: 3, count: 40 });
return candidates
  .filter((driver) => nowMs - driver.lastSeenMs < 30_000)
  .filter((driver) => driver.status === 'available')
  .sortBy((driver) => etaService.seconds(driver, pickup));

3. Matching is a lease plus a durable decision

The matcher consumes ride_42 from a regional queue and asks Redis for a bounded candidate list. It filters the list by class, driver status, last-seen age, and any policy constraints, then asks a routing dependency for a current ETA. Ranking is deliberately a policy boundary: proximity is a first filter, not a claim that the closest driver is always the best product outcome.

The dangerous race is simple. Two matcher workers see Maya in the same candidate list. If both send an offer before either records state, Maya may accept two rides. A database flag can coordinate the state, but an in-memory timer disappears when the worker crashes. Redis provides a small, explicit lease: SET offer:driver:d-184 <token> NX EX 10 succeeds for one worker and returns no value for the other. The key expires automatically if the owner disappears.

The Open source: Redis SET documentation describes NX as “only set when absent” and the expiry as automatic release. The Open source: Redis distributed-lock guidance also explains why release must compare the stored random value: an old worker must not delete a newer owner's lease after its own TTL has elapsed. We use that pattern for offer contention, while PostgreSQL remains the authority for assignment.

Voxel diagram showing a ride request ranked against drivers, a ten-second offer lease, and timeout recovery.
The offer lease prevents two matcher workers from asking the same driver at the same time.
One token owns the short offer window; one conditional transaction owns the durable assignment.

Redis

SET offer:driver:d-184 offer_7_token NX EX 10

Lua

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0

SQL

BEGIN;

UPDATE driver_offers
SET status = 'accepted', accepted_at = now()
WHERE id = :offer_id
  AND token_hash = :token_hash
  AND status = 'open'
  AND expires_at > now()
RETURNING driver_id;

UPDATE drivers
SET status = 'on_trip', active_ride_id = :ride_id
WHERE id = :driver_id
  AND status = 'available'
  AND active_ride_id IS NULL;

UPDATE rides
SET status = 'accepted', driver_id = :driver_id, accepted_at = now()
WHERE id = :ride_id AND status = 'offered';

-- Commit only when all three updates changed one row.
COMMIT;

4. Offers are a multi-step workflow

Sending a notification starts a human-in-the-loop process. Maya may accept immediately, decline, lose connectivity, or never open the app. The workflow records the offer ID, driver ID, ride ID, rank position, token hash, and expiry before asking APNs or FCM to deliver it. A push is a transport attempt, not an event log: Open source: Apple's APNs documentation describes best-effort delivery that can be delayed or reordered, and Open source: FCM's architecture overview shows that FCM hands Apple-device messages to APNs. Both reinforce the same rule: the notification service is a provider-neutral boundary, and the app must reconcile from the ride state.

The workflow waits for an accept or decline command with the same offer token. On accept, it performs the conditional transaction above and emits ride.accepted only after commit. On decline, it token-releases Maya and advances to Leo. On timeout, the lease's TTL and the durable offer expiry converge on the same transition: mark the offer expired, release if the token still matches, and enqueue the next candidate. A late accept sees status != open and becomes a no-op with the current ride state returned.

A queue alone is enough to preserve a request across a worker restart, but the timers and conditional callbacks become hard to reason about as the flow grows. A durable workflow engine such as Open source: Temporal stores workflow progress so a timeout, retry, or worker crash resumes the same state machine. Teams can use a managed queue plus a persisted deadline instead; the invariant is durable intent, not a particular vendor.

The same offer ID is visible in the happy path, the timeout, and the retry; logs are evidence, not the authority.
Voxel city intersection with a broken dispatch path rerouted around it
A timeout is a state transition with a durable retry, not a timer owned by one matcher process.

5. A ride is an event stream, not a mutable spinner

Once Maya accepts, both apps need a coherent view of ride_42. The ride service records each transition with a monotonically increasing sequence: accepted@18, en_route_pickup@19, arrived@20, and so on. The outbox transaction writes the ride change and its event together, so a committed state cannot wait forever for an in-memory publish callback. Consumers use the event ID or sequence as an idempotency key.

While the app is foregrounded, a WebSocket or SSE stream pushes ordered updates and driver locations. After a network break, the client reconnects with lastEventSeq=20; the stream service replays missing events before switching back to live. A push notification can wake a backgrounded app, but the app then fetches the current ride. This avoids showing “driver arriving” after a cancellation simply because an older push arrived late.

Location points do not need the same durability as lifecycle transitions. The map can interpolate between recent points and mark a driver stale after the heartbeat window. The ride row, offer state, and billing handoff do need durable ordering. Keeping these streams separate lets us sample GPS aggressively without weakening the invariant.

Voxel diagram showing matched, en route, arrived, in-trip, and completed states converging on a trip event store.
Clients render a sequence of durable ride events; they do not invent state from a moving spinner.
A reconnect asks for missing events; it never asks a notification to prove the current state.

TEXT

GET /rides/ride_42/events?after=20
Accept: text/event-stream

// replay
id: 21
event: ride.en_route_pickup
data: {"rideId":"ride_42","driverId":"d-184"}

// live
id: 22
event: driver.location
data: {"lat":40.7412,"lon":-73.9991,"seenAt":1764612381}

TYPESCRIPT

const command = { rideId: 'ride_42', type: 'mark_arrived', idempotencyKey: 'cmd_22' };
const result = await rideApi.command(command);
// A retry with cmd_22 returns the same committed transition.
return result.eventSeq;

6. Pricing and completion stay on the same authority path

The fare estimate is a versioned input: route request, pricing-model version, currency, surge factor, and expiry. The client can display it and Alice can accept it, but the server reloads the quote by ID. At completion, the trip service records the measured route and any allowed adjustments, then writes a final fare calculation against that snapshot. This makes dynamic pricing a policy input rather than a mutable value supplied by the phone.

The completion command is idempotent. If Maya taps Complete twice or the network retries the first request, the second request finds completed and returns the same receipt. Billing consumes the committed outbox event; it does not race the ride row for ownership. A payment provider or notification provider can retry independently because the provider operation ID and event ID have unique constraints in the consumer database.

The same ordering protects cancellation. A rider cancellation before assignment marks the ride cancelled and prevents future offers. A cancellation after pickup becomes a different business transition, with its own pricing and support policy. Keeping command guards close to the state machine is clearer than scattering “if cancelled” checks through every notification handler.

Driver heartbeatAdaptive; reject after 30 s staleCandidate freshness onlyExclude and rebuild on next ping
Offer lease10 s acceptance windowOne outstanding offer per driverToken release or TTL expiry
Live event streamReplay from last sequenceDelivery order for clientsFetch current ride and resubscribe
Ride databaseCurrent committed transactionAssignment and lifecycleRetry with the same idempotency key
Concrete budgets and authorities for the running ride; tune the numbers with observed latency and safety margins.

7. Peak demand is a queue and a placement problem

A concert ending or a storm can create a burst of requests in one geohash. Letting every request synchronously fan out into matching causes the matcher, location index, and routing dependency to fail together. The regional request queue absorbs the burst, partitions work by city or geohash, and exposes a backlog metric that can drive horizontal scaling. A request is acknowledged only after it is durably queued; a worker acknowledges it only after the offer decision is committed or the workflow has a durable retry.

Geo-sharding keeps hot paths close to their candidates. Most searches stay inside one shard; a pickup near a boundary may query two neighboring cells and merge results. The shard is a routing and capacity boundary, not a correctness boundary: the final driver assignment still uses one authoritative transaction. Read replicas can serve rider history and completed trips, while the active ride write follows the primary for that shard.

Backpressure should be visible to the product. If the queue is growing or Redis geo capacity is unhealthy, the gateway can return “matching is busy, retrying” with a request ID instead of timing out silently. The durable workflow keeps Alice's request alive, and the app can show a bounded status. Dropping the request is the worst failure because it leaves no state to recover.

Driver heartbeat staleCandidate disappears or ETA confidence fallsExclude now; re-add on a fresh signed pingStale location cannot make an offer
Matcher worker crashesOffer is waitingWorkflow resumes from durable offer expiryThe request is not dropped
APNs/FCM delayedDriver sees an old or duplicate pushApp fetches ride state by event sequencePush never assigns a ride
Two accepts raceOne request returns conflictConditional SQL returns committed winnerOne driver, one active ride
Redis shard unavailableNew matching pauses in one regionBackpressure, failover, then heartbeat rebuildNo unsafe check-then-set fallback
The failure is assigned to the layer that can recover it without inventing a new authority.

Whole-system summary

The focused figures explain the local mechanisms. The Nodefall canvas below reconnects them: the rider and driver clients enter through the edge, fare and ride commands pass through the ride service, GPS updates feed the regional geo index, the matcher and offer lease coordinate the human response, and PostgreSQL plus the outbox publish the authoritative lifecycle. Follow Alice's ride_42 through the four walkthrough steps and notice where the design deliberately tolerates staleness while refusing to guess at correctness boundaries.

Uber-like ride sharing: quote, match, move, and complete

The complete ride-sharing request map: quote, live location, regional matching, token-owned offers, durable ride state, and sequenced updates.

Open canvas

Sources and what is original

These references support specific concepts. Alice, Maya, Leo, ride_42, the lifecycle composition, failure matrix, code examples, Nodefall graph, and generated visuals are original Nodefall work.

Each link sits beside the concept it supports, so a reader can verify the boundary without leaving the narrative.