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-estimatesaccepts pickup and destination; the server owns the route, price inputs, currency, and expiry.POST /ridesaccepts a fare ID plus an idempotency key; it createsride_42inrequestedstate.POST /drivers/locationderives driver identity and server time from the authenticated session; the body cannot impersonate another driver.PATCH /ride-offers/:offerIdaccepts 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 cache | Quote preview for the same route | No: load the stored quote snapshot | Recompute when expired |
| Redis geo index | Fresh-ish nearby driver candidates | No: filter and assign in PostgreSQL | Rebuild from the next heartbeat |
| Offer lease | One matcher owns a ten-second attempt | No: lease only reduces contention | Token release or TTL expiry |
| PostgreSQL ride row | Current committed lifecycle state | Yes: assignment and transitions | Retry idempotent commands and outbox |
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.

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 WITHCOORDTYPESCRIPT
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.

Redis
SET offer:driver:d-184 offer_7_token NX EX 10Lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0SQL
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.

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.

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 heartbeat | Adaptive; reject after 30 s stale | Candidate freshness only | Exclude and rebuild on next ping |
| Offer lease | 10 s acceptance window | One outstanding offer per driver | Token release or TTL expiry |
| Live event stream | Replay from last sequence | Delivery order for clients | Fetch current ride and resubscribe |
| Ride database | Current committed transaction | Assignment and lifecycle | Retry with the same idempotency key |
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 stale | Candidate disappears or ETA confidence falls | Exclude now; re-add on a fresh signed ping | Stale location cannot make an offer |
| Matcher worker crashes | Offer is waiting | Workflow resumes from durable offer expiry | The request is not dropped |
| APNs/FCM delayed | Driver sees an old or duplicate push | App fetches ride state by event sequence | Push never assigns a ride |
| Two accepts race | One request returns conflict | Conditional SQL returns committed winner | One driver, one active ride |
| Redis shard unavailable | New matching pauses in one region | Backpressure, failover, then heartbeat rebuild | No unsafe check-then-set fallback |
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.
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.
Primary inspiration: Open source: Hello Interview's Uber system-design breakdown for the requirements, ride flow, location, matching, timeout, and geo-sharding questions.
Geospatial indexing: Open source: Redis GEOADD and Open source: GEOSEARCH for the hot candidate index.
Offer contention: Open source: Redis SET and lock guidance for the token-owned lease and safe release.
Durable radius fallback: Open source: PostGIS ST_DWithin for indexed persistent geospatial filtering.
Transport and workflow recovery: Open source: APNs, Open source: FCM, and Open source: Temporal for notification, client-reconciliation, and durable-timeout boundaries.
Each link sits beside the concept it supports, so a reader can verify the boundary without leaving the narrative.
