System Design · Interview Deep Dive

Design Uber / Ola: Ride-Hailing System Design

A million drivers pinging GPS every few seconds, riders expecting a match in seconds, and a trip that must never be double-assigned. Here is how to design it, with the numbers and the trade-offs interviewers probe.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 17 min read
City map with hexagonal geospatial grid cells, moving cars and a highlighted rider pickup point
TL;DR
  • Ride-hailing is a write-heavy real-time geospatial problem: drivers stream GPS every ~4 seconds, so location writes dwarf ride requests by 1,000× or more.
  • Keep live driver positions in an in-memory index sharded by city and geo-cell (geohash, S2 or H3). Never write every ping to the trips database.
  • Matching = candidate search over neighbouring cells → rank by road ETA → atomically reserve one driver → offer with timeout → fall back to the next.
  • Model the trip as an explicit state machine with conditional (compare-and-set) transitions; it is the source of truth for billing and disputes.
  • Surge is a per-cell, per-minute supply/demand ratio, smoothed and capped (and in India, regulated).

Every system design candidate has heard "Design Uber". Fewer can explain why the design is shaped the way it is. The shape comes from one fact: the data that matters most, where every driver is right now, changes every few seconds and is only useful for a few seconds. That pushes you toward in-memory geospatial indexes, streaming pipelines and soft state, while the trip itself (money, disputes, safety) needs strict, durable consistency. A strong answer is mostly about keeping those two worlds separate.

I will use Indian context (Ola, Uber India, Rapido, autos and bikes, cash and UPI) because that is what interviewers at Indian product companies like to push on. If geospatial indexing is new to you, skim geohash vs quadtree vs S2/H3 first.

Requirements for a Ride-Hailing System

Functional

  • Riders see nearby available vehicles on a map and a fare estimate for a pickup and drop.
  • Riders request a ride; the system matches a driver within seconds; the driver accepts or declines.
  • Both parties see each other's live location during pickup and the trip.
  • Trip lifecycle: arrive, OTP verification, start, end, fare calculation, payment (cash, UPI, wallet, card), ratings.
  • Dynamic pricing (surge) when demand exceeds supply.

Non-functional

  • Low latency matching: a driver offer within a few seconds of request.
  • High availability for request and matching paths; a down dispatch means stranded riders.
  • Consistency where money and assignment are involved: one driver per trip, one trip per driver, exactly one charge.
  • Freshness over durability for locations: a 5-second-old position is fine, losing a ping is fine.
  • Works on poor networks and low-end Android phones, which matters a lot in Indian cities.

Out of scope unless asked: driver onboarding/KYC, pooling/shared rides, scheduled rides, fraud detection, driver payouts.

Capacity Estimation at India Scale

These numbers are interview assumptions sized for a large Indian ride-hailing player, not company disclosures. State them and move on.

AssumptionValue
Drivers online at evening peak1 million (cars, autos, bikes)
Location update intervalEvery 4 seconds
Completed rides per day10 million
Ride requests vs completed rides~2× (retries, cancellations)
Riders with app open at peak5 million
Location ping size~100 bytes (driver_id, lat, lng, heading, speed, ts, status)
  • Location writes: 1M ÷ 4 s = 250,000 updates/sec at peak. This is the dominant write load.
  • Ride requests: 20M/day ÷ 86,400 ≈ 230/sec average; peak maybe 5× ≈ 1,200/sec. Tiny compared with location writes.
  • "Nearby cars" reads: 5M open apps refreshing every ~5 s ≈ 1M reads/sec at peak, which is why this view is often served from a coarser cached aggregate rather than the exact index.
  • Location bandwidth: 250K × 100 B = 25 MB/s ingress, trivial for networks but ~2 TB/day if archived. Archive to cheap storage for ETA training and trip disputes.
  • Live index memory: 1M drivers × ~200 B per entry (with index overhead) ≈ 200 MB. The whole country's live supply fits in RAM on a single machine; we shard for throughput and fault isolation, not size.
250K/s
Location writes
~1.2K/s
Peak ride requests
~200 MB
Live driver index
< 5 s
Target time to offer
💡
The insight to say out loud"The live driver index is small but extremely write-hot, so it belongs in memory, partitioned by city. Durable storage is only for trips, payments and an archive of pings." That one sentence frames the rest of the interview.

API Design

Rider and driver APIs
# Rider
POST /v1/fare-estimates      { pickup:{lat,lng}, drop:{lat,lng}, product:"auto" }
  → { estimate_id, fare_range:[142,168], surge:1.2, eta_pickup_s:240, expires_at }
POST /v1/rides               { estimate_id, payment_method:"upi" }
  headers: Idempotency-Key: 6f1c...
  → 202 { ride_id, status:"MATCHING" }
GET  /v1/rides/{ride_id}     → full trip state
POST /v1/rides/{ride_id}/cancel

# Driver
PUT  /v1/drivers/me/status   { status:"ONLINE" | "OFFLINE" }
POST /v1/drivers/me/locations   # batched, fire-and-forget (or UDP/MQTT/gRPC stream)
  [{lat,lng,heading,speed,accuracy,ts}, ...]
POST /v1/offers/{offer_id}/accept | /decline
POST /v1/rides/{ride_id}/arrive | /start {otp} | /end

# Server → clients: persistent connection (WebSocket/MQTT)
  rider:  DRIVER_ASSIGNED, DRIVER_LOCATION, DRIVER_ARRIVED, TRIP_STARTED...
  driver: NEW_OFFER (expires in 15s), RIDE_CANCELLED...

Two details worth calling out. The ride request carries an Idempotency-Key so a retried request on a flaky network does not create two rides. And the fare estimate is a server-side object with an expiry, so the price the rider accepted is the price locked into the ride, not one recomputed after surge changes.

Data Model

Schema sketch
drivers            (driver_id PK, city_id, vehicle_type, rating, status, ...)
riders             (rider_id PK, phone, rating, default_payment, ...)

rides              -- sharded by city_id or ride_id; strongly consistent
  ride_id PK, rider_id, driver_id NULL, city_id, product,
  status, version INT,                 -- for compare-and-set
  pickup_lat, pickup_lng, drop_lat, drop_lng,
  quoted_fare, surge_multiplier, final_fare,
  otp, requested_at, assigned_at, started_at, ended_at

ride_events        -- append-only audit of every state change
  ride_id, seq, from_status, to_status, actor, ts, payload_json

driver_live        -- IN MEMORY only (Redis / custom geo service)
  driver_id → { lat, lng, h3_cell, status, vehicle_type, last_seen_ts }
  h3_cell   → set of driver_ids (per vehicle_type)

location_archive   -- Kafka → object storage / columnar store
  driver_id, ts, lat, lng, speed, ride_id NULL

The ride_events table is cheap insurance. When a rider disputes a fare or a safety team investigates a trip, you replay exactly who changed what and when.

High-Level Architecture

Driver app ──(every 4s)──► Location Gateway ──► Kafka: driver.locations │ │ ▼ ├─► Archive (S3/HDFS) Geo Index Service ├─► Surge/Demand job (in-memory, sharded by └─► ETA model training city → H3 cell) ▲ Rider app ──► API Gateway ──► Ride Service ──► Dispatch/Matching Service ▲ │ │ (rides DB) │ │ │ ▼ ▼ │ │ Pricing Service ◄── Surge store (per cell) │ │ │ │ └──► Routing/ETA Service (road graph + traffic) │ └──── Push / WebSocket Gateway ◄──── trip + location events │ Payment Service (UPI/cards/wallet)

Partition almost everything by city. Riders in Bengaluru never match drivers in Pune, so a city is a natural shard boundary for the geo index, dispatch and surge. Large cities like Bengaluru or Delhi NCR split further by geo-cell ranges. A bad deploy or overload in one city does not take down others.

Deep Dive 1: Location Ingestion at 250K Writes per Second

  1. Batch on the device. The driver app samples GPS every second, but uploads a small batch every 4 seconds over a persistent connection. This saves battery and radio wake-ups.
  2. Stateless gateways. A fleet of location gateways authenticates, validates (drop impossible jumps, poor accuracy), and enriches each ping with its H3 cell.
  3. Update the live index directly for low latency, keyed by driver_id. The write is an overwrite: last writer wins, with a timestamp check to ignore out-of-order pings.
  4. Publish to Kafka partitioned by driver_id for everything asynchronous: archive, surge, ETA training, trip tracking for the rider.
  5. Expire stale drivers. If no ping arrives for ~30 seconds, mark the driver unavailable for matching. Indian networks drop in tunnels, flyovers and basements; treat silence as "unknown", not "available".

Why not write pings into Cassandra and query it? You could store them, but querying "available drivers near X" against a disk-backed store at this freshness and rate is wasteful. A Redis-style in-memory structure handles it comfortably. Redis even ships geo commands:

Redis GEO commands
# key per city + vehicle type
GEOADD drivers:blr:auto 77.6408 12.9784 driver:8812
GEOSEARCH drivers:blr:auto FROMLONLAT 77.6400 12.9780 BYRADIUS 2 km ASC COUNT 20 WITHDIST

Redis GEO uses a 52-bit geohash stored in a sorted set, so it works well for a prototype or moderate scale. At the very high write rates of the largest cities, companies build custom sharded geo services, but the interface is the same: update(driver, lat, lng) and nearby(lat, lng, radius, filters). For Redis internals see the Redis deep dive.

Deep Dive 2: Geospatial Indexing with Geohash vs H3

A geospatial index maps 2D points to 1D keys so that "nearby" becomes "same or adjacent key".

Geohash

Geohash interleaves latitude and longitude bits and encodes them in base-32. Longer strings mean smaller cells, and nearby points usually share a prefix.

Geohash lengthApprox. cell sizeUse
5~4.9 km × 4.9 kmCity-zone aggregates, surge display
6~1.2 km × 0.6 kmDriver candidate search
7~153 m × 153 mDense areas, pickup points

The weakness: two points a metre apart can sit across a cell boundary with completely different prefixes, so you must always search the cell plus its 8 neighbours. Cells are also rectangles whose shape distorts with latitude.

H3 hexagons

Uber open-sourced H3, a hierarchical hexagonal grid with 16 resolutions. Average cell areas are roughly 5.2 km² at resolution 7, 0.74 km² at resolution 8 and 0.1 km² at resolution 9. Hexagons have one kind of neighbour, all equidistant from the centre, so "k rings around me" is a uniform disk-like search. That uniformity matters for smoothing surge and demand across neighbours.

Python (h3 v4 API)
import h3

RES = 8

def cell_of(lat, lng):
    return h3.latlng_to_cell(lat, lng, RES)

def candidate_cells(lat, lng, k):
    # the pickup cell plus all cells within k hex steps
    return h3.grid_disk(cell_of(lat, lng), k)

# live index: cell -> set of available driver ids
index = {}

def update_driver(driver_id, lat, lng, old_cell=None):
    new_cell = cell_of(lat, lng)
    if old_cell and old_cell != new_cell:
        index.get(old_cell, set()).discard(driver_id)
    index.setdefault(new_cell, set()).add(driver_id)
    return new_cell

def nearby_drivers(lat, lng, want=10, max_k=4):
    found = set()
    for k in range(1, max_k + 1):          # expand rings until enough supply
        for c in candidate_cells(lat, lng, k):
            found |= index.get(c, set())
        if len(found) >= want:
            break
    return found

The expanding-ring loop is how matching adapts to density: in Koramangala at 7 pm one ring may give 30 autos; on the outskirts at 2 am you may need four rings. (For production you would compute only the new ring each iteration with grid_ring, but the logic is the same.)

CriterionGeohashQuadtreeH3
Cell shapeRectanglesAdaptive squaresHexagons
Neighbour searchCell + 8 neighboursTree traversalUniform k-rings
Fast-moving updatesEasy (string key)Costly rebalancingEasy (64-bit id)
Best forSimple DB prefix queriesStatic, uneven density (places)Moving objects, aggregation

Quadtrees shine for mostly static points like restaurants (see the proximity service design) but rebalancing a tree 250,000 times a second is painful, so grid-based keys win for moving drivers.

Deep Dive 3: Driver Matching (Dispatch)

Matching is where the interview is won. A naive "nearest driver by straight line" is wrong in Indian cities: the nearest auto may be across a flyover with a 2 km U-turn.

  1. Candidate generation. Query the geo index for available drivers of the right vehicle type within k rings of pickup. Cap at ~20–50 candidates.
  2. ETA ranking. Call the routing service for road-network ETA from each candidate to the pickup (a batched one-to-many query). Straight-line distance is only a pre-filter.
  3. Scoring. Combine ETA with secondary factors: driver acceptance rate, heading (a driver already moving toward the rider is better), time since last trip for fairness, vehicle preferences.
  4. Reserve atomically. Before offering, reserve the top driver: a compare-and-set from AVAILABLE to OFFERED(ride_id) with a TTL. If it fails, someone else got them; move to the next.
  5. Offer with timeout. Push the offer to the driver app with a 10–15 second window. Accept → assign. Decline or timeout → release the reservation and offer the next candidate.
  6. Give up gracefully. After N attempts or ~60 seconds, mark NO_DRIVER_FOUND, notify the rider, and log the miss for supply analytics.
Redis reservation (atomic)
# succeeds only if no other dispatch attempt holds this driver
SET driver_lock:8812 ride:55120 NX PX 15000
# release only if we still own it (Lua for atomic compare-and-delete)
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1]) else return 0 end"
     1 driver_lock:8812 ride:55120

Greedy vs batched matching

Greedy (each request grabs its best driver immediately) is simple and fast but globally suboptimal: rider A takes the driver who was the only good option for rider B. Batched matching collects requests in a cell for a short window (a second or two), builds a rider × driver cost matrix of ETAs, and solves an assignment problem (Hungarian algorithm or min-cost flow) to minimise total pickup time. Uber has publicly described moving toward batched matching. Mention the trade-off: a little added latency for better global efficiency, used in dense areas at peak; greedy is fine for sparse areas.

Sequential vs broadcast offers

Offering one driver at a time is fair and avoids wasted driver attention but is slower when drivers decline often. Broadcasting to the top 3 and taking the first acceptance is faster but annoys the two who lose the race. Many Indian apps with high decline rates (drivers rejecting short or cash trips) lean toward small parallel batches. Name the choice and the reason.

Deep Dive 4: The Trip State Machine

The ride record is the contract between rider, driver and company. Make its states and transitions explicit and enforce them on the server.

REQUESTED ──► MATCHING ──► DRIVER_ASSIGNED ──► DRIVER_ARRIVED ──► IN_PROGRESS │ │ │ │ │ │ ▼ ▼ ▼ ▼ │ NO_DRIVER_FOUND CANCELLED_BY_* CANCELLED_BY_* COMPLETED │ (fee rules) (no-show fee) │ └──────────► CANCELLED_BY_RIDER ▼ PAYMENT_PENDING │ ▼ PAID
Java: guarded transitions with optimistic locking
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;

enum RideStatus {
    REQUESTED, MATCHING, NO_DRIVER_FOUND, DRIVER_ASSIGNED, DRIVER_ARRIVED,
    IN_PROGRESS, COMPLETED, PAYMENT_PENDING, PAID, CANCELLED
}

final class RideStateMachine {
    private static final Map<RideStatus, Set<RideStatus>> ALLOWED = Map.of(
        RideStatus.REQUESTED,       EnumSet.of(RideStatus.MATCHING, RideStatus.CANCELLED),
        RideStatus.MATCHING,        EnumSet.of(RideStatus.DRIVER_ASSIGNED,
                                               RideStatus.NO_DRIVER_FOUND, RideStatus.CANCELLED),
        RideStatus.DRIVER_ASSIGNED, EnumSet.of(RideStatus.DRIVER_ARRIVED,
                                               RideStatus.MATCHING,   // driver cancelled: rematch
                                               RideStatus.CANCELLED),
        RideStatus.DRIVER_ARRIVED,  EnumSet.of(RideStatus.IN_PROGRESS, RideStatus.CANCELLED),
        RideStatus.IN_PROGRESS,     EnumSet.of(RideStatus.COMPLETED),
        RideStatus.COMPLETED,       EnumSet.of(RideStatus.PAYMENT_PENDING, RideStatus.PAID),
        RideStatus.PAYMENT_PENDING, EnumSet.of(RideStatus.PAID)
    );

    static boolean canMove(RideStatus from, RideStatus to) {
        return ALLOWED.getOrDefault(from, Set.of()).contains(to);
    }
}

// Persistence: the transition succeeds only if nobody changed the row meanwhile.
// UPDATE rides SET status = ?, version = version + 1, driver_id = ?
//  WHERE ride_id = ? AND status = ? AND version = ?;
// rowsUpdated == 0  →  lost the race; reload and decide again.

Why this matters: the rider taps cancel at the same instant the driver taps accept. Both requests read MATCHING. With the conditional update, exactly one wins; the loser gets a clean "ride already cancelled" or "driver already assigned" response. Without it you get a driver driving to a cancelled ride, which is a real and very visible bug class. The same idea (guarded transitions plus version checks) appears in ticket booking and payment systems.

The start-trip OTP common in Indian apps is a nice touch to mention: DRIVER_ARRIVED → IN_PROGRESS requires the rider's 4-digit code, which prevents a driver from starting a trip for the wrong passenger.

Deep Dive 5: Surge Pricing

Surge has two jobs: ration scarce supply and pull drivers toward demand. Mechanically:

  1. Count demand and supply per cell per window. A stream job consumes ride requests, fare-estimate views (a proxy for latent demand) and driver locations, and computes per H3 cell per minute: demand D and available supply S.
  2. Map ratio to multiplier. m = f(D/S) through a tuned piecewise curve, e.g., 1.0 below a ratio of 1, rising gradually above it.
  3. Smooth. Average with neighbouring cells (k-ring) and with the previous windows so prices do not flicker or create sharp borders where riders walk 100 m to save 40%.
  4. Cap and publish. Apply regulatory and business caps, write to a fast surge store keyed by cell, and let the pricing service read it when quoting.
  5. Lock at quote time. The multiplier is stored on the fare estimate; the rider pays what they saw.
⚠️
India-specific: surge is regulatedThe Ministry of Road Transport and Highways' Motor Vehicle Aggregator Guidelines cap surge as a multiple of base fare (the 2020 guidelines set 1.5×; the 2025 revision has been reported to allow up to 2×), and states adopt and enforce these differently. In an interview, say "caps are configurable per city because regulation differs" and you will look well informed. Confirm current rules before quoting a number in production.

Deep Dive 6: ETA Calculation

ETAs show up three times: pickup ETA in the fare estimate, dispatch ranking, and trip ETA to destination. A credible design:

  • Road graph from map data (OpenStreetMap or commercial), with edges weighted by travel time per time-of-day.
  • Fast routing with preprocessing techniques such as contraction hierarchies so one-to-many queries take milliseconds instead of running Dijkstra from scratch.
  • Live traffic derived from the same driver ping stream: speeds observed on each road segment over the last few minutes update edge weights.
  • ML correction: a model predicts the residual between routed ETA and actual ETA using features like city, hour, weather, pickup type (a mall exit or a gated society adds minutes).

Routing is a whole interview of its own; see Design Google Maps for tiles, routing and ETA in depth.

Failures, Bottlenecks and Trade-offs

ScenarioMitigation
Geo index node for a city crashesReplicas per shard; on failover the index rebuilds within seconds because every driver pings again within 4 s. Soft state heals itself.
Hot cell (stadium after an IPL match)Split city shards by cell ranges; cap candidates; switch to batched matching; queue requests with honest ETAs.
Driver app loses network mid-tripApp buffers GPS points and uploads on reconnect; fare uses reconstructed route; trip state does not depend on pings.
Rider retries requestIdempotency key on ride creation returns the existing ride.
Dispatch service crash mid-offerReservation TTL expires and frees the driver; ride stays in MATCHING and a new dispatcher instance picks it up from a durable queue.
Payment fails after tripMove to PAYMENT_PENDING, retry, fall back to cash/collect later. Never block trip completion on the payment gateway.

Real-time updates to riders (driver moving on the map) flow over persistent connections. The trade-offs between WebSockets, SSE and polling are covered in WebSockets vs SSE vs long polling; MQTT is also popular for mobile because of its small overhead on bad networks.

How This Shows Up in Interviews

What interviewers probe

  • "Why not store locations in MySQL with a lat/lng index?" Answer with write rate, freshness and the fact that a B-tree on two columns does not do 2D range queries well.
  • "Two riders, one driver?" Atomic reservation plus conditional updates.
  • "Driver crosses a cell boundary every few seconds?" Remove from the old cell, add to the new, both keyed by driver_id; tolerate a brief duplicate.
  • "How do you scale to a new city?" City-scoped shards and config (products, surge caps, fare tables).
  • "How would you design Uber Pool?" Matching becomes route insertion: can a new rider be inserted into an existing trip within a detour budget? Mention it, do not solve it unless asked.

Common mistakes

🚫
Red flagsPersisting every ping to the primary database. Matching by straight-line distance. No story for concurrent accept/cancel. One global dispatch service with no city partitioning. Treating surge as a static fare table. Forgetting that drivers go silent and must expire from the index.

The closest sibling question is Swiggy / Zomato food delivery, which adds a third party (the restaurant) and batching multiple orders per rider. If you can explain ride-hailing cleanly, you are 70% of the way to that one too.

Frequently Asked Questions

How does Uber find nearby drivers?▾
Driver locations are kept in an in-memory geospatial index keyed by cell (geohash or Uber's H3 hexagons). For a pickup point, the system computes the rider's cell plus neighbouring rings of cells, collects available drivers in those cells, then ranks them by road-network ETA rather than straight-line distance.
What is H3 and why does Uber use hexagons?▾
H3 is an open-source hierarchical hexagonal grid created by Uber. Hexagons have six neighbours all at the same distance from the centre, so ring searches and smoothing across neighbours (for surge or demand forecasting) behave uniformly, unlike squares where diagonal neighbours are farther away.
How often should a driver app send location updates?▾
Commonly every 3 to 5 seconds while online, and more often during an active trip for smooth map animation. That cadence gives fresh enough positions for matching while keeping write volume and battery drain manageable. Updates are fire-and-forget; a lost ping is simply superseded by the next one.
How do you prevent two riders being assigned the same driver?▾
Use an atomic reservation on the driver: a compare-and-set on the driver's state (AVAILABLE to OFFERED) or a Redis SET NX with a TTL, held by one dispatch attempt at a time. Only the attempt that wins the reservation can send the offer; others skip that driver.
How is surge pricing calculated?▾
For each geographic cell and short time window, the system compares demand (ride requests and app opens) with supply (available drivers). The ratio maps to a multiplier through a tuned curve that is smoothed over neighbouring cells and time and capped. In India, aggregator guidelines limit how high the multiplier can go relative to base fare.
Which database does a ride-hailing app use for trips?▾
Trips need strong consistency for state transitions and payments, so a sharded relational store or a strongly consistent distributed SQL database is typical, sharded by city or trip ID. High-volume location pings go to an in-memory index and a log like Kafka, not to the trips database.
Is Uber system design asked in Indian product company interviews?▾
Yes. Ride-hailing and its cousins (food delivery, proximity search) are among the most common HLD questions at Indian product companies and FAANG India offices because they test geospatial indexing, real-time updates and consistency in one problem.

Ace the Uber-Style HLD Round

Prepflix's system design program drills ride-hailing, food delivery and maps designs with live mock interviews so you can explain matching and geo-indexing under pressure.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.