Design Uber: System Design & Architecture

Build a ride-hailing platform: a rider requests a ride, the system finds a nearby driver within seconds, exactly one driver gets assigned, and both sides watch the trip unfold live on the map.

Scope

In Scope

  • Rider flow: get a fare estimate, request a ride, get matched with a nearby driver, track them live
  • Driver flow: go online/offline, stream location, receive ride offers one at a time, accept or decline
  • Matching with strong consistency: a driver is never assigned to two rides, and one ride gets exactly one driver
  • Live location tracking during pickup and trip
  • The ride lifecycle state machine: requested → matched → arriving → in progress → completed (or cancelled)
  • Basic fare estimation: distance and time rates with a surge multiplier applied as a given input

Out of Scope

  • The surge pricing algorithm itself (demand forecasting, price optimization): we consume its output - a multiplier per area - as a black box
  • Payment processing: on completion we hand a final fare to a payments system and move on
  • Routing and ETA computation internals: we call a maps provider; building one is its own problem
  • Driver onboarding, background checks, and document verification
  • Shared rides (UberPool): matching multiple riders into one route is a much harder variant worth its own design
  • Ratings, customer support, and fraud detection

💡 What does "matching" involve, and why does it need strong consistency?

Matching (also called dispatch) is pairing one ride request with one driver. The consistency requirement comes from the physical world: a driver is a physical resource, like a bookable time slot in Design Calendly. If two riders are ever assigned the same driver, one of them is standing on a curb waiting for a car that is driving away in another direction - the failure is visible, personal, and immediate. That makes assignment the one place in this system that must be transactionally correct, no matter how many matching servers are working the same busy neighborhood at once. Meanwhile almost everything else here - positions on the map, ETAs, surge levels - is allowed to be a few seconds stale, because the physical world moves slowly enough that "roughly right, right now" beats "exactly right, too late." Splitting the system along that line is most of the design.

Functional Requirements

  • A rider can get a fare estimate for a pickup and destination before requesting
  • A rider can request a ride and be matched with a nearby available driver within seconds
  • A driver can go online or offline, and while online receives ride offers one at a time
  • A driver can accept or decline an offer; a declined or timed-out offer moves to the next candidate
  • A rider watches their matched driver approach live on the map, and both sides see live trip progress
  • The ride moves through a defined lifecycle, and each transition (arrived, trip started, completed, cancelled) updates both apps
  • A driver is never assigned to two rides at once, and a rider has at most one active ride

Non-functional Requirements

  • Matching speed: median < 15 seconds, p95 < 30 seconds from request to assigned driver in urban areas
  • Assignment consistency: strong - exactly one driver per ride and one active ride per driver, enforced by the database, not by hope
  • Location freshness: driver pings are searchable within ~1 second of arrival, so matching never offers rides to drivers who left the area
  • Tracking latency: the rider sees a driver position at most a few seconds old
  • Scale: 20M rides/day, ~2M drivers online at peak, ~400K location updates/sec at peak
  • Availability: 99.9%+ for the request/match path; under stress, degrade to longer waits - never to wrong assignments

💡 Why is the map allowed to be stale when assignment is not?

The map is a cache of the physical world. Every driver position we hold is already slightly wrong the moment it arrives - the car kept moving. A position that is 4 seconds old is normal and fine: matching ranks candidates by ETA, and a few seconds of drift changes nothing about who is closest. So the whole location pipeline is built for freshness and volume, not durability or precision: fast in, fast out, gone in seconds. Assignment is the opposite. "Which driver is on which ride" is not a reflection of the physical world - it IS the system's decision, and both people rearrange their next twenty minutes around it. There is no acceptable staleness for a booking: it is either true or it is a double-booking. So assignment lives in a transactional database with a constraint that makes the bad state impossible to write. This is the same per-operation consistency argument as Design Tinder (strong for swipes, eventual for the feed), pushed further by physical stakes: eventual consistency for where things are, strong consistency for who is committed to whom.

Back-of-the-Envelope Estimates

  • Assume 150M monthly riders, 5M registered drivers, ~2M drivers online at peak, 20M rides/day across ~500 cities
  • A write is a location ping, a ride state change, or an offer response. A read is a proximity search, a tracking view, or a fare estimate
  • The location firehose dominates everything: 2M online drivers x 1 ping every 5 seconds = 400K updates/sec at peak; over a day, roughly 17B pings. This is ~1,000x every other write in the system combined
  • Each ping is obsolete ~5 seconds later when the next one replaces it - the firehose is enormous AND worthless to archive, which is the key storage insight
  • Rides are quiet by comparison: 20M/day ÷ 86,400 ≈ 230 requests/sec average, ~2.3K/sec peak; each ride generates ~10 state-changing writes spread over the trip → a few thousand precious transactional writes/sec at peak
  • Matching: each request triggers a proximity search plus a few offer rounds → ~5-10K geo queries/sec at peak
  • Tracking: ~300-500K rides in progress at peak, each streaming a driver position every few seconds to one rider → ~150K pushes/sec over persistent connections
  • Storage: ride records at ~2KB x 20M/day ≈ 40GB/day (~15TB/year) - comfortable in PostgreSQL. Raw GPS is never durably stored; only a downsampled route (~10KB) is kept per completed trip for receipts and disputes

💡 What the estimates mean: the busiest data is the least worth keeping

The numbers split the system into two workloads with opposite needs, and the design falls out of respecting the split. The location firehose (400K writes/sec) is ephemeral by nature: only the latest position per driver matters, every write overwrites the previous one, and a position not refreshed for 30 seconds should disappear anyway (the driver's app died or they went offline). That profile - latest-value-only, huge rate, self-expiring - belongs in memory, not in a database: a Redis geospatial index per city, where a ping is one in-place update and stale entries evict themselves via TTL. Writing 17B pings/day into PostgreSQL would buy durability for data that is meaningless within seconds - all cost, no benefit. This is the third appearance of the ephemeral-data rule in this set: cursors in Design Excalidraw and typing indicators in Design WhatsApp made the same argument at smaller scale. Rides are the opposite: a few thousand writes/sec of genuinely precious, transactional data - who is assigned to whom, what state the trip is in, what was charged. Small enough for PostgreSQL without ceremony, and PostgreSQL is exactly what the assignment invariant wants: a partial unique index that makes "one active ride per driver" a rule the database enforces atomically. One more thing falls out of the estimates: geography is the natural shard key. A proximity search in Tokyo never touches São Paulo data - rides are intrinsically local. So the geo index shards cleanly by city (each city's live map fits on a node), and the platform regionalizes with almost no cross-shard traffic. Few systems hand you sharding this kind; it is worth pointing out in an interview that ride-hailing is one of them.

High-level Architecture

  • API Gateway: Authenticates riders and drivers, rate-limits, and sanity-checks the ping firehose at the front door
  • Location Service: Ingests driver pings, validates them (GPS jumps, impossible speeds), and updates the in-memory geo index; for drivers on an active ride, also forwards the position to the rider's tracking channel
  • Geo Index (Redis GEO, sharded by city): The live map - latest position per online driver, TTL-evicted, queried by matching for "available drivers near this pickup"
  • Matching Service: Runs the offer loop - proximity query, rank candidates by ETA, lock one driver at a time (short-TTL hold), offer, and on accept hand off to the Ride Service for the transactional assignment
  • Ride Service (PostgreSQL): Owner of the ride state machine and the source of truth for assignment; the partial unique index "one active ride per driver" is the final referee against double-booking
  • Fare Service: Turns a maps-provider ETA/distance plus city rate card plus the surge multiplier (external input) into a quoted price, persisted so the charge honors the quote
  • Tracking Gateway (WebSocket): Persistent connections pushing driver positions to the matched rider during pickup and trip
  • Maps Provider (external): Routing, distances, and ETAs - consumed, not built

Data/User Flow Diagram

LOCATION FIREHOSE - always on while a driver is online
[Driver app] --ping every ~5s--> [Location Service]
                                       |
                                       +--> [Geo Index (Redis GEO, per city)]
                                       |     latest position only, TTL ~30s
                                       +--if on active ride--> [Tracking GW]
                                                                ==push==> rider

REQUEST -> MATCH -> ASSIGN
[Rider app] --> [API Gateway] --> [Fare Service] --quote--> rider confirms
                                       |
                                       v
                                [Matching Service]
                                       |
                          [Geo Index] "available drivers near pickup"
                                       |  ranked by ETA
                                       v
                    lock candidate #1 (Redis SET NX, 15s TTL)
                    --offer--> driver's app
                       | accept                | decline / timeout
                       v                       v
                [Ride Service (PostgreSQL)]   release lock,
                 transaction:                  offer candidate #2
                 driver still available?
                 write assignment
                 UNIQUE "one active ride
                 per driver" = final referee
                       |
                       v
        rider + driver both notified; trip runs through the
        state machine: matched -> arriving -> in_progress -> completed

💡 What is a geohash, and why shard the map by city?

A geohash encodes a latitude/longitude pair into a short string - say "9q8yy" - with a lovely property: points that are near each other on the map share a prefix. The string works like a grid address: each added character subdivides the cell into smaller cells, so a prefix length picks a cell size. "Find drivers near me" stops being math over coordinates and becomes "look in my cell and its 8 neighbors" - a handful of key lookups. (The neighbors matter because you might be standing at a cell edge; searching only your own cell would miss a driver 50 meters away across the boundary.) Redis GEO commands (GEOADD, GEOSEARCH) are built on exactly this idea, packing geohash-style scores into a sorted set. Contrast this with Design Tinder, which put locations in PostgreSQL with PostGIS. Same kind of query, opposite data: profiles are durable records updated occasionally, so a database index fits. Driver positions update every 5 seconds and expire in 30 - putting them behind a database's durability machinery is paying for exactly what the data does not need. Sharding by city works because proximity queries are intrinsically local: no search in Tokyo ever needs São Paulo's drivers. Each city's live map fits comfortably on one node (2M drivers globally is a few hundred MB), hot megacities can split further by geohash prefix, and the failure blast radius of any one node is one city, not the platform.

Data Model & Storage

  • Riders and Drivers (PostgreSQL): identity, vehicle info, driver status (offline / available / on_ride), rating. Modest size, modest writes, wants constraints - standard relational territory.
  • Rides (PostgreSQL): the precious table. One row per ride: who, pickup/dropoff, state, quoted and final fare, a timestamp per state transition, and a pointer to the stored route. Partial unique indexes enforce "one active ride per driver" and "one active ride per rider" at the database level.
  • Fare Quotes (Redis, TTL ~5 min): quote_id → priced estimate. Persisting the quote briefly is what lets us promise the price shown is the price charged, even if surge shifts a moment later.
  • Geo Index (Redis GEO, one keyspace per city): driver_id → latest position for available drivers. In-memory, overwritten every ping, TTL-evicted. This is the live map, and it is deliberately not durable.
  • Offer Locks (Redis, SET NX with 15s TTL): "offer:{driver_id}" → ride_id. A driver is considering at most one offer at any moment; expiry auto-releases abandoned offers.
  • Trip Routes (Object Storage): a downsampled polyline (~10KB) per completed ride, written once at trip end, referenced from the ride row - the only GPS data that survives the day, kept for receipts and disputes.
  • Surge multipliers (Redis, per city cell): the external pricing system's output, read by the Fare Service. An input, not something we compute.

💡 Why driver locations never touch the database

Do the math the estimates set up: 17B pings/day into PostgreSQL would be one of the largest write workloads imaginable, buying durability for data that is stale in 5 seconds and worthless in 30. Durability is for data you will want later; there is no "later" for a driver position. The Redis GEO index matches the data's real semantics instead. Latest-value-only: a ping is an overwrite (GEOADD on the same driver_id replaces the old position), so the index never grows with traffic - it holds one entry per online driver, a few hundred MB globally. Self-expiring: each entry rides a TTL refreshed by every ping, so a driver whose app crashes simply vanishes from the map within 30 seconds. That eviction is not a cleanup chore - it IS the correct behavior, automatically: unreachable drivers must not be offered rides. The failure mode and the feature are the same mechanism. What about history? The one legitimate consumer is the completed trip - receipts, fare disputes, safety reviews - and it needs a route, not a firehose: at trip end we write a downsampled polyline to object storage and pin its pointer to the ride row. Everything else evaporates, which is also the right privacy posture: the system cannot leak movement history it never stored. Rule of thumb, third time in this set (Excalidraw cursors, WhatsApp typing indicators, now this): match the store to the data's lifespan. Seconds-lived data gets memory; years-lived data gets a database.

API Design

POST /v1/fare/estimates
Price a pickup/dropoff pair; returns a quote_id honored for ~5 minutes.
POST /v1/rides
Request a ride against a quote; matching begins asynchronously. Accepts an Idempotency-Key so a retry never creates two rides.
GET /v1/rides/:id
Current ride state, driver details, and transition timeline.
POST /v1/rides/:id/cancel
Cancel; allowed transitions and fees depend on the current state.
WS /v1/rides/:id/track
Rider's live channel: driver position, ETA updates, and state changes pushed during the ride.
PUT /v1/drivers/me/location
The ping - highest-volume endpoint in the system; answered from memory, never touches the database.
PUT /v1/drivers/me/status
Go online / offline.
POST /v1/offers/:id/accept
Driver accepts an offer (decline via POST .../decline); may return 409 if the offer expired or was matched elsewhere.

Low-level Design

Location Service - the ping firehose (step by step):

  • 1) While online, the driver app sends its position every ~5 seconds (adaptive: slower when stationary, saving battery and bandwidth).
  • 2) The service sanity-checks the ping - impossible speeds and GPS teleports are dropped, both for map quality and because spoofed locations are an attack vector.
  • 3) GEOADD into the driver's city index, overwriting the previous position, and refresh the TTL.
  • 4) If the driver is on an active ride, the position is also pushed down the rider's tracking channel.
  • 5) Nothing is written durably. If ingestion is ever overloaded, pings are dropped rather than queued - a queued stale position is worse than none, because the next ping five seconds later supersedes it anyway.

Matching Service - from request to assignment (the genuinely tricky part):

  • 1) The rider confirms a quote; a ride row is created in state requested.
  • 2) Proximity query: search the pickup's geohash cell plus neighbors for available drivers, filter by vehicle type, rank by ETA (a maps call for the top few, straight-line distance to shortlist).
  • 3) The offer loop, one driver at a time: acquire that driver's offer lock with Redis SET NX and a 15-second TTL. The lock means one driver never sees two competing offers, and the TTL means an ignored offer self-releases.
  • 4) Push the offer to the driver's app. Decline or timeout → release the lock, move to candidate #2.
  • 5) Accept → a single PostgreSQL transaction: re-check the driver is still available, write the assignment, flip ride state to matched and driver status to on_ride. The partial unique index (one active ride per driver) is the final referee - if two matching servers somehow raced past the Redis lock on the same driver, the second COMMIT fails cleanly and that matcher moves on.
  • 6) No candidates at all → widen the radius, then hold the request in a per-area queue with honest feedback to the rider.
  • Name the pattern: a fast, expiring hold up front and a database constraint as the last word - the Design Calendly hold-and-commit, third appearance in this set after Tinder's match creation.

Ride Service - the state machine:

  • States: requested → matched → arriving → in_progress → completed, with cancelled reachable from the early states.
  • Every transition is server-validated (a trip cannot complete before it starts; a cancel after pickup follows different fee rules) and timestamped - those timestamps ARE the trip record: wait time, trip duration, and fare inputs all derive from them.
  • Transitions are idempotent: the driver's "start trip" tap retried over flaky mobile network is a no-op the second time, same retry-plus-dedupe discipline as Design WhatsApp's message sends.
  • Downstream behavior keys off state everywhere: tracking permissions, cancel fees, driver availability, fare finalization.
  • On completed: compute the final fare from actuals (honoring the quote), hand it to payments (out of scope), write the downsampled route to object storage, flip the driver back to available - which re-inserts them into the geo index for the next match.

Tracking - watching your driver approach:

  • On match, the rider's app opens WS /v1/rides/:id/track; the subscription is keyed by ride_id and authorized to exactly the two participants.
  • Driver pings during the ride dual-write: geo index as always, plus a push down this channel.
  • The rider sees a smooth glide, not a 5-second hop, because the app interpolates - animating the car between updates. The smoothness is a client-side illusion, and knowing that lets the backend run at a battery-friendly ping rate instead of streaming at 10Hz.
  • Connection drops are routine on mobile: on reconnect the channel replays current state (position, ride status) rather than a history - only the present matters, so there is nothing to catch up on. Compare Design WhatsApp, where reconnect must drain everything missed: tracking is a "latest value" stream, messaging is an "every value" stream, and their reconnect stories differ for exactly that reason.

Fare Service - estimates that keep their word:

  • 1) Ask the maps provider for route distance and ETA.
  • 2) Apply the city rate card (base + per-km + per-minute) and the pickup cell's surge multiplier - which arrives from the out-of-scope pricing system as a plain number.
  • 3) Persist the quote with a ~5 minute expiry and return quote_id.
  • 4) The ride request references the quote_id, and final billing honors it: surge doubling seconds after the rider saw a price must not double what they pay. A quote is a small promise, and persisting it is what makes the promise keepable.
  • 5) Estimates for the same cell pair are briefly cached - fare curiosity is heavy, cheap traffic.

Database Types and Schemas

Database Schemas

- Drivers (PostgreSQL)
  driver_id UUID PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  phone VARCHAR(20) UNIQUE NOT NULL,
  vehicle JSONB,                      -- make, model, plate, type
  status driver_status_enum DEFAULT 'offline',  -- offline|available|on_ride
  rating DECIMAL(3,2),
  city_id VARCHAR(50),
  created_at TIMESTAMP DEFAULT NOW(),

  INDEX idx_driver_city_status ON (city_id, status)

- Rides (PostgreSQL) -- the precious table: small, transactional, constrained
  ride_id UUID PRIMARY KEY,
  rider_id UUID NOT NULL REFERENCES riders(rider_id),
  driver_id UUID REFERENCES drivers(driver_id),  -- NULL until matched
  quote_id UUID NOT NULL,
  pickup_point GEOGRAPHY(POINT) NOT NULL,
  pickup_address TEXT,
  dropoff_point GEOGRAPHY(POINT) NOT NULL,
  dropoff_address TEXT,
  state ride_state_enum DEFAULT 'requested',
    -- requested|matched|arriving|in_progress|completed|cancelled
  surge_multiplier DECIMAL(3,2) DEFAULT 1.0,
  quoted_fare DECIMAL(8,2),
  final_fare DECIMAL(8,2),
  route_ref VARCHAR(512),             -- object-storage key, written at completion
  requested_at TIMESTAMP DEFAULT NOW(),
  matched_at TIMESTAMP,
  pickup_at TIMESTAMP,
  completed_at TIMESTAMP,
  cancelled_at TIMESTAMP,
  cancel_reason TEXT,

  -- THE REFEREE: double-booking is unwritable, no matter how matchers race
  UNIQUE (driver_id) WHERE state IN ('matched','arriving','in_progress')
  UNIQUE (rider_id)  WHERE state IN ('requested','matched','arriving','in_progress')
  INDEX idx_rider_history  ON (rider_id, requested_at DESC)
  INDEX idx_driver_history ON (driver_id, requested_at DESC)

Redis keys (per city where noted)

- Geo Index -- the live map; deliberately not durable
  GEOADD "geo:{city}:available" lon lat driver_id     (ping = overwrite)
  Companion TTL key "loc:{driver_id}" EX 30           (no ping -> evicted)
  Query: GEOSEARCH near pickup, radius ~2-5km

- Offer Lock -- one offer per driver at a time
  SET "offer:{driver_id}" ride_id NX EX 15
  -- NX: only the first matcher wins; EX 15: ignored offers self-release

- Fare Quotes
  "quote:{quote_id}" -> { fare, surge, route_summary }  TTL 5 min

- Surge multipliers (external input, read-only to us)
  "surge:{city}:{geohash_cell}" -> 1.4

API Request/Response Payloads

POST /v1/fare/estimates
Request
{
  "pickup":  { "lat": 37.7749, "lng": -122.4194 },
  "dropoff": { "lat": 37.7849, "lng": -122.4094 },
  "vehicle_type": "standard"
}
Response
{
  "quote_id": "q_7a2b8f4e",
  "fare": 12.75,
  "currency": "USD",
  "surge_multiplier": 1.2,
  "eta_to_pickup_minutes": 4,
  "trip_minutes": 15,
  "expires_at": "2026-07-05T18:09:11Z"
}

// The quote is persisted until expires_at: the price shown is the price
// charged, even if surge changes a moment after the rider saw it.
POST /v1/rides
Request
{
  "quote_id": "q_7a2b8f4e"
}
Response
{
  "ride_id": "r_4c8e6f2a",
  "state": "requested",
  "matching": true,
  "track_url": "wss://api.example.com/v1/rides/r_4c8e6f2a/track"
}

// Matching runs asynchronously - the rider connects to track_url and
// receives the match the moment a driver accepts. An Idempotency-Key
// header makes a retried request return this same ride, never a second one.
Driver offer (pushed) and POST /v1/offers/:id/accept
Request
// pushed to the driver's app over their connection:
{
  "offer_id": "o_9d4f1a2b",
  "ride_id": "r_4c8e6f2a",
  "pickup_address": "123 Market St",
  "eta_to_pickup_minutes": 4,
  "estimated_fare": 12.75,
  "expires_in_seconds": 15
}
Response
// driver taps accept -> POST /v1/offers/o_9d4f1a2b/accept
{
  "ride_id": "r_4c8e6f2a",
  "state": "matched",
  "rider": { "name": "Jamie", "rating": 4.9 },
  "pickup": { "lat": 37.7749, "lng": -122.4194,
              "address": "123 Market St" }
}

// If the 15s lock expired, or the assignment transaction lost a race,
// accept returns 409 offer_no_longer_available and the driver is
// immediately eligible for the next offer. Declining or ignoring the
// offer releases the lock the same way.
PUT /v1/drivers/me/location (the firehose endpoint)
Request
{
  "lat": 37.7799,
  "lng": -122.4144,
  "heading": 45,
  "speed_kmh": 38,
  "ts": "2026-07-05T18:05:20Z"
}
Response
{
  "ok": true
}

// The busiest endpoint in the system (~400K req/sec at peak) and the
// cheapest: validate, overwrite one in-memory geo entry, refresh a TTL,
// maybe forward to one tracking channel. It never touches a database.
WS /v1/rides/:id/track - what the rider receives
Response
{
  "type": "driver_location",
  "ride_id": "r_4c8e6f2a",
  "state": "arriving",
  "driver": {
    "name": "Alex",
    "vehicle": { "make": "Toyota", "model": "Camry",
                 "plate": "ABC123", "color": "Silver" },
    "position": { "lat": 37.7774, "lng": -122.4169, "heading": 210 }
  },
  "eta_minutes": 3
}

// Pushed every few seconds; the app animates between updates so the car
// glides instead of hopping. State transitions (arriving, in_progress,
// completed) arrive on this same channel. Only the ride's two
// participants are authorized to subscribe.

💡 Three terms from the flows: geohash cell, partial unique index, interpolation

Geohash cell: one grid square of the map, addressed by a string prefix - longer prefix, smaller square. Proximity search means "my cell plus its 8 neighbors," and the neighbors are not optional: a rider standing at a cell edge has drivers 50 meters away in the adjacent square, and searching only one cell would miss them. This boundary case is the classic follow-up question, so know it. Partial unique index: a uniqueness rule that applies only to rows matching a condition - here, UNIQUE(driver_id) WHERE state is active. It allows unlimited completed rides per driver (history) while making a second concurrent one physically unwritable. It is the same "referee inside the database" as Design Calendly's exclusion constraint and Design Tinder's match constraint, specialized with a WHERE clause: enforce the invariant exactly where it applies and nowhere else. Interpolation: the client-side animation between position updates. The server sends a position every few seconds; the app moves the car smoothly along the line between them. Users perceive a live continuous feed, but the smoothness is manufactured on the phone - which is what frees the backend to run at a battery- and bandwidth-friendly update rate. A good reminder that perceived real-time and actual update rate are separate design dials.

Concluding Discussion

Potential Bottlenecks

  • Issue: A stadium empties and 50K ride requests hit one geohash cell in minutes, with every matcher fighting over the same few hundred drivers.
    Mitigation: switch the hot cell from one-offer-at-a-time to batch matching - solve many rider-driver pairings in one pass - queue the overflow with honest wait estimates, and let surge (the out-of-scope system) pull more drivers toward the area.
  • Issue: The location firehose (~400K/sec peak) overwhelms ingestion.
    Mitigation: it shards naturally by city, pings are drop-not-queue under pressure (the next ping supersedes anyway), and adaptive ping rates slow stationary or idle drivers - most of the firehose is drivers waiting, not moving.
  • Issue: A megacity's geo index outgrows one Redis node.
    Mitigation: split the city's keyspace by geohash prefix across nodes; searches already read cell-by-cell, so they stripe across sub-shards without any query change.

Single Points of Failure

  • A city's geo index node: its crash blinds matching in that city. Replicate it - and note the unusually kind recovery: since every driver re-pings within 5 seconds, a cold replacement rebuilds the entire live map from scratch in seconds. Self-healing falls out of the data's own refresh rate.
  • The Rides DB primary: new assignments and state transitions stall if it is down. Hot standby with automatic failover; meanwhile tracking (a Redis-and-WebSocket path) keeps working, so trips in progress stay watchable even while new matches pause.
  • The external maps provider: ETAs and routes vanish if it is down. Cache recent ETAs per cell pair, fall back to straight-line-distance heuristics (worse ranking, still functional), and degrade estimate precision rather than refusing rides.

Security Improvements

  • Location privacy has teeth here: idle drivers' precise positions are shown to riders only as fuzzy availability; exact coordinates flow only between a matched pair during an active ride; and raw pings are never stored - the system cannot leak movement history it does not have.
  • Sanity-check and rate-limit pings: teleporting or impossibly fast "drivers" are GPS spoofing, used to farm incentives or game surge, and get dropped at the gateway.
  • Tracking channels are authorized per ride_id to exactly the two participants - a leaked URL without the participant's token gets nothing.
  • Return the same 404 for "ride does not exist" and "ride is not yours," so ride ids cannot be probed.

Critical Knowledge

If the interviewer asks "how do you find nearby drivers fast?":

  • Naive answer fails first: computing distance to every online driver per request is a 2M-row scan, thousands of times a second.
  • Geohash the map: encode positions so nearby points share string prefixes, making "near me" a lookup of my cell plus 8 neighbors - Redis GEO implements exactly this over sorted sets.
  • Know the boundary case: the neighbor cells are mandatory, or a driver 50m away across a cell edge is invisible.
  • And the contrast that shows judgment: Design Tinder used PostGIS for the same query shape, correctly - profiles are durable, slowly-updated records. Driver pings update every 5 seconds and expire in 30, so the index belongs in memory. Same query, different data lifespan, different store.

If the interviewer asks "how do you stop two riders from getting the same driver?":

  • Two layers, each doing what it is good at. Fast layer: a Redis SET NX offer lock per driver with a 15-second TTL - one offer on a driver's screen at a time, self-releasing if ignored.
  • Final layer: the accept runs a PostgreSQL transaction that re-checks availability and writes the assignment under a partial unique index (one active ride per driver) - so even if two matchers race past the lock, the second commit fails cleanly.
  • The bad state is unwritable, not merely unlikely - constraints beat careful code.
  • Name the lineage: this is Design Calendly's hold-and-commit (hold a slot, commit under a constraint), also used for Design Tinder's match creation. Physical resources - slots, matches, drivers - all want the same two-layer shape.

If the interviewer asks "why not store driver locations in the database?":

  • Run the numbers: ~17B pings/day for data whose useful life is 5 seconds - durability would cost enormously and buy literally nothing, since no one ever reads an old position.
  • The data's semantics are latest-value-only and self-expiring, which is precisely a TTL'd in-memory geo set: each ping overwrites, silence evicts, and eviction is itself correct behavior - a crashed driver app disappears from the matchable map automatically.
  • The one durable artifact is the downsampled route written once per completed trip, for receipts and disputes.
  • The transferable rule (third time in this set, after Excalidraw cursors and WhatsApp typing): match the store to the data's lifespan - seconds-lived data gets memory, years-lived data gets a database.

If the interviewer asks "why model the ride as a state machine?":

  • Because a trip is a long-running, real-world process, and every behavior in the system branches on where it stands: cancel fees, tracking access, driver availability, fare finalization all key off state.
  • Explicit states with server-validated transitions make illegal histories unrepresentable - a trip cannot complete before it starts, a cancel after pickup follows different rules - instead of hoping scattered if-statements agree.
  • Transitions are idempotent (a retried "start trip" tap is a no-op), which is the same retry-safety discipline as message sends in Design WhatsApp, applied to button taps on flaky mobile networks.
  • And the timestamps on transitions are not bookkeeping - they ARE the business record: wait time, trip duration, and the fare all derive from them.

If the interviewer asks "how does this scale to 500 cities?":

  • Point out the gift: rides are intrinsically local - no query in Tokyo ever needs São Paulo's data - so geography is a natural shard key with almost no cross-shard traffic. Few systems hand you a partition this clean; contrast Design WhatsApp, where any user may message any user worldwide and no such geographic cut exists.
  • Concretely: geo index per city (hot megacities split by geohash prefix), regional service deployments near their cities for latency, and city-level rate cards and rules as configuration.
  • Handle the one leak: trips crossing boundaries (airport runs between adjacent regions) - search neighbor regions when the pickup sits near an edge.
  • Regionalization also gives failure isolation (one city down, platform up) and data-residency compliance essentially for free.