Hard

Design Ride Sharing (Uber/Lyft)

Design the core of a ride-sharing service: riders request trips, nearby drivers are found and matched in seconds, and both parties track each other live on a map. The hard parts are geospatial indexing of constantly moving drivers, a low-latency matching engine, a massive location-update write load, and dynamic (surge) pricing.

1Requirements

Functional

  • Riders request a ride with pickup and destination; the system returns an ETA and fare estimate up front.
  • Match the rider to a suitable nearby driver within a few seconds, considering distance/ETA, driver status, and vehicle type.
  • Drivers stream location updates; riders see the assigned driver moving on the map in near real time.
  • Drivers can accept or decline offers; declines trigger re-matching to the next candidate.
  • Track trip lifecycle (requested, matched, en route, in progress, completed) and compute the final fare including surge.
  • Support surge pricing per area based on real-time supply and demand.

Non-functional

  • Matching latency under ~3 seconds end to end; nearby-driver queries under ~100ms.
  • Handle millions of concurrent drivers each sending a location update every ~4 seconds.
  • High availability for the request/match path; a rider unable to hail is lost revenue and trust.
  • Location data can be slightly stale (seconds) but trip state and payments must be strongly consistent.
  • A driver must never be assigned to two trips at once (no double dispatch).

2Back-of-envelope estimation

Active drivers at peak2 million concurrent
Location update write QPS~500K/s
Ride requests~350/s avg, ~2K/s peak
Live location memory~100 GB
Location history storage~2 TB/day

3API design

POST /v1/rides

Rider requests a trip: {pickup, destination, vehicle_type}. Returns ride_id, fare estimate with surge multiplier, and ETA; matching proceeds async with status pushed over the rider's WebSocket.

POST /v1/drivers/me/location (or WebSocket/gRPC stream)

Driver app streams {lat, lng, heading, ts} every ~4s. In practice this is a persistent connection, not per-update HTTP, to cut handshake overhead at 500K updates/s.

POST /v1/rides/{rideId}/offer-response

Driver accepts or declines a dispatch offer within the offer TTL (~10s); accept transitions the trip to matched atomically or fails if another driver already took it.

GET /v1/rides/{rideId}

Trip state, assigned driver, and live ETA; the same data is pushed to both parties over WebSocket so this is mainly for reconnection recovery.

4High-level design

Both apps hold persistent WebSocket (or gRPC streaming) connections to a gateway fleet; a connection registry maps user -> gateway node so backend services can push to any client. Driver location updates arrive over these connections and are dropped onto a Kafka topic, giving one durable stream that fans out to every consumer that needs positions.

The location service consumes the stream and maintains the live index: for each geographic cell (geohash prefix or H3 cell), a Redis structure holds the set of available drivers with their latest coordinates. Updates overwrite in place, so the index stores only current state; a parallel consumer appends the full history to Cassandra for offline use.

When a ride is requested, the matching service computes the pickup's cell plus its neighbor ring (to avoid boundary misses), fetches candidate drivers from the index, filters by status and vehicle type, ranks by road-network ETA from the routing service rather than straight-line distance, and offers the trip to the best candidate with a ~10s TTL, cascading to the next on decline or timeout.

Trip state lives in a strongly consistent transactional store. Acceptance is an atomic conditional update (trip: matching -> matched, driver: available -> on_trip); whichever accept lands first wins and the loser gets a clean 'already taken' failure. This is the guardrail against double dispatch regardless of how racy the surrounding pipeline is.

The surge service consumes the same location stream plus the request stream, computes supply/demand ratios per cell per minute, and publishes multipliers to a cache read by the pricing service at quote time. The multiplier shown at request time is locked into the trip record so the fare cannot drift mid-ride.

5Data model

trip

id BIGINT PK, rider_id BIGINT, driver_id BIGINT, status VARCHAR, pickup_lat DECIMAL(9,6), pickup_lng DECIMAL(9,6), dest_lat DECIMAL(9,6), dest_lng DECIMAL(9,6), surge_multiplier DECIMAL(3,2), quoted_fare DECIMAL(10,2), final_fare DECIMAL(10,2), requested_at TIMESTAMP, completed_at TIMESTAMP

Source of truth for state transitions; conditional updates on status enforce the trip state machine.

driver_live_location (Redis)

key geo_cell_id, member driver_id, value {lat, lng, heading, status, updated_at}

Sharded by region; entries expire if not refreshed within ~15s so crashed drivers vanish from matching automatically.

location_history (Cassandra)

driver_id BIGINT, bucket DATE, ts TIMESTAMP, lat DECIMAL(9,6), lng DECIMAL(9,6), trip_id BIGINT, PK ((driver_id, bucket), ts)

Append-only, time-bucketed partitions; powers trip replay, fare disputes, and ETA model training.

surge_cell

cell_id VARCHAR, window_start TIMESTAMP, open_requests INT, available_drivers INT, multiplier DECIMAL(3,2), PK (cell_id, window_start)

6Deep dives

Geospatial indexing: geohash vs. quadtree vs. H3

Geohash encodes lat/lng into a base-32 string where shared prefixes imply proximity, so 'find nearby drivers' becomes a prefix lookup at a chosen precision (geohash-6 cells are roughly 1.2km x 0.6km, a sensible dispatch radius in cities). It is simple and maps directly onto Redis keys. Its weaknesses: cells are rectangles of uneven aspect ratio, cell sizes jump discretely between precision levels, and two adjacent points can have completely different prefixes across a cell boundary, so you must always query the 8 neighboring cells too.

A quadtree adapts to density by splitting cells that exceed a driver-count threshold, giving small cells in Manhattan and huge ones in Wyoming. That adaptivity is attractive, but a mutable in-memory tree under 500K writes/s needs careful concurrency control and is harder to shard than a flat cell keyspace. Static grids with density-appropriate precision per region capture most of the benefit with far less machinery.

H3, Uber's own hexagonal hierarchical index, is what they actually use: hexagons have near-uniform distance to all neighbors (no corner-distance distortion like squares), every cell has exactly 6 neighbors at the same resolution, and the hierarchy supports clean aggregation for surge heatmaps. In an interview, geohash + neighbor queries on Redis is a perfectly defensible baseline; name H3 and explain the hexagon advantage as the production-grade refinement.

The matching engine and double-dispatch prevention

Ranking candidates by straight-line distance is the classic rookie mistake: a driver 200m away across a river or a divided highway may be 10 minutes away by road. The matcher should fetch a generous candidate set (say 20 drivers) from the geo index cheaply, then call the routing service for real ETAs on that shortlist, then rank by ETA blended with driver acceptance rate and fairness signals. This two-phase filter keeps expensive routing calls off the hot path for all but a handful of candidates.

Dispatch is offer-based: lock the top candidate softly (mark them 'offered' in the index so concurrent matches skip them), push the offer with a ~10s TTL, and cascade to the next candidate on decline or timeout. The hard guarantee against double dispatch does not live in the index, which is eventually consistent by design; it lives in the trip store, where acceptance is a compare-and-set on both the trip row and the driver's status. Even if two matching workers somehow offer the same driver two trips, only one accept can commit.

Batching is a meaningful optimization at scale: instead of matching each request greedily the instant it arrives, collect requests in a small window (1-2s) per area and solve the assignment jointly, which measurably lowers aggregate pickup ETA during peaks. Mention it as an evolution, not the v1.

Handling 500K location updates per second

The write path is the throughput monster, so keep it dumb and fast: persistent connections at the gateway, minimal validation, straight into Kafka partitioned by region or driver_id. Kafka acts as the shock absorber and as the single source for multiple consumers (live index, history writer, surge, ETA models) without duplicating the ingest path.

The live index consumer does last-write-wins upserts into region-sharded Redis. Two details matter. First, cell transitions: when a driver moves from cell A to B, the update must remove them from A and add to B; keeping a driver -> current-cell reverse mapping makes this a cheap two-key operation. Second, TTLs: every entry expires in ~15s unless refreshed, so drivers whose app died or who lost signal drop out of matching automatically instead of appearing as phantom supply.

Bandwidth and battery push toward adaptive update rates: a driver on a highway between trips can report every 10-15s, one approaching a pickup every 1-2s. The client can also batch and delta-encode points. On the read side, the rider map does not need every raw point; interpolating/snapping the driver's position along the known route between 4-second updates yields smoother UX than higher update frequency would.

Surge pricing mechanics

Surge exists to fix a marketplace imbalance in real time: when open requests exceed available drivers in an area, raising price both suppresses marginal demand and pulls drivers toward the hot zone. Compute it per cell per short window (e.g., 1-5 minutes) as a function of the request/driver ratio, smoothed over recent windows so the multiplier does not oscillate wildly, and use coarser cells (H3 res 7-8) than dispatch to avoid noisy micro-zones and cliff effects at cell borders.

Operationally the crucial property is quote consistency: the multiplier is evaluated once at quote time, shown to the rider, and frozen into the trip record on acceptance. Recomputing surge at trip end, or letting the quote silently expire mid-flow, is both a UX disaster and a regulatory risk. Quotes carry a short validity window (a couple of minutes) after which the rider must re-request.

Surge is also a feedback loop with the dispatch system: publishing a surge heatmap to driver apps redistributes supply, which lowers surge, which is exactly the intended equilibrium. Guard against pathological loops (drivers chasing surge that vanishes on arrival) by smoothing and by showing predicted rather than instantaneous multipliers.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Node.js + Socket.IO for live connections, Redis hashes keyed by geohash cell, Postgres for trips, haversine ranking (OSRM later), one Hetzner VM.

  1. 01Scaffold Express + Socket.IO with separate driver and rider namespaces; the driver app emits {lat, lng} every 4 seconds.
  2. 02Store live locations in geohash-6 cell hashes in Redis with a 15s freshness window, plus a driver-to-cell reverse key so cell moves are a cheap two-key update.
  3. 03Create a Postgres trips table with a status state machine: requested, matching, matched, in_progress, completed, cancelled.
  4. 04Build POST /rides: quote fare as distance x rate x surge, insert the trip in 'matching', then query the pickup's cell plus its 8 neighbors for candidate drivers.
  5. 05Rank candidates by haversine distance for the MVP, push an offer with a 10s TTL to the top driver's socket, and cascade to the next on decline or timeout.
  6. 06Implement accept as a conditional UPDATE ... WHERE status = 'matching'; zero rows updated means someone else won, so tell the driver it is taken.
  7. 07During the trip, relay the driver's position to the rider's socket and mark completion, computing the final fare from the frozen quote.
  8. 08Surge MVP: a per-cell counter of open requests vs. available drivers per minute; multiplier = clamp(requests / max(drivers, 1), 1, 3), frozen into the quote.

Geohash bucket index: update and neighbor query

typescript
import geohash from "ngeohash";

const PRECISION = 6; // roughly 1.2km x 0.6km cells
const FRESH_MS = 15_000;

export async function updateDriver(id: string, lat: number, lng: number) {
  const cell = geohash.encode(lat, lng, PRECISION);
  const prev = await redis.getset("drv:" + id, cell);
  if (prev && prev !== cell) await redis.hdel("cell:" + prev, id);
  await redis.hset(
    "cell:" + cell, id,
    JSON.stringify({ lat, lng, t: Date.now() })
  );
  await redis.expire("drv:" + id, 15); // dead apps vanish from matching
}

export async function nearbyDrivers(lat: number, lng: number) {
  const center = geohash.encode(lat, lng, PRECISION);
  const cells = [center, ...geohash.neighbors(center)]; // avoid edge misses
  const out: Array<{ id: string; lat: number; lng: number }> = [];
  for (const c of cells) {
    const members = await redis.hgetall("cell:" + c);
    for (const [id, raw] of Object.entries(members)) {
      const p = JSON.parse(raw);
      if (Date.now() - p.t < FRESH_MS) out.push({ id, lat: p.lat, lng: p.lng });
    }
  }
  return out;
}

Nearest-driver ranking and atomic dispatch accept

typescript
function haversineKm(a: Pt, b: Pt) {
  const R = 6371, d = Math.PI / 180;
  const dLat = (b.lat - a.lat) * d, dLng = (b.lng - a.lng) * d;
  const h = Math.sin(dLat / 2) ** 2 +
    Math.cos(a.lat * d) * Math.cos(b.lat * d) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}

export async function matchRide(tripId: number, pickup: Pt) {
  const candidates = (await nearbyDrivers(pickup.lat, pickup.lng))
    .map((c) => ({ ...c, dist: haversineKm(pickup, c) }))
    .sort((x, y) => x.dist - y.dist)
    .slice(0, 5);
  for (const c of candidates) {
    const accepted = await offerWithTtl(c.id, tripId, 10_000);
    if (!accepted) continue; // decline or timeout: next candidate
    // atomic compare-and-set is the double-dispatch guardrail
    const res = await pool.query(
      "UPDATE trips SET status = 'matched', driver_id = $1 " +
      "WHERE id = $2 AND status = 'matching'",
      [c.id, tripId]
    );
    if (res.rowCount === 1) return c.id; // this accept won
  }
  return null; // no driver found; widen the search ring
}

Bottlenecks & failure modes

  • Location write throughput (~500K/s) makes any disk-backed synchronous store on the hot path a non-starter; Kafka + in-memory index is the load-bearing decision.
  • Dense-city hotspots: one geohash cell in midtown Manhattan can hold thousands of drivers while rural cells are empty; use finer precision or adaptive cells in dense regions.
  • Routing-service ETA calls during matching are expensive; without the two-phase candidate filter they become the matching latency bottleneck.
  • Event spikes (concert lets out, airport surge) multiply requests in one cell by 50x in minutes; matching workers and the routing service need regional burst headroom.
  • WebSocket gateway fleet holds millions of long-lived connections; connection rebalancing during deploys must not drop trips mid-dispatch.

Key takeaways

  • Separate the firehose from the truth: locations flow through Kafka into an ephemeral in-memory geo index, while trip state lives in a small, strongly consistent store.
  • Geo indexing is a cell-mapping problem: geohash/H3 prefix buckets plus neighbor-ring queries turn 'nearby drivers' into O(1) key lookups.
  • Prevent double dispatch with atomic conditional state transitions in the trip store, never with the eventually consistent index.
  • Rank by road ETA, not straight-line distance, using a cheap-filter-then-expensive-rank two-phase match.
  • Freeze the surge multiplier at quote time; pricing consistency is a correctness requirement, not a nicety.

Brush up on the underlying topics