Design a Ride-Sharing App (Uber/Ola)
A ride-sharing platform connects passengers to nearby drivers in real time. The system must track millions of moving vehicles, match riders to the optimal driver within seconds, calculate dynamic surge pricing, and manage the full trip lifecycle through a reliable state machine — all at the scale of 25M trips per day.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Passengers can request a ride by providing pickup and drop-off locations
- The system matches the request to the best nearby available driver within seconds
- Drivers receive ride requests and can accept or decline
- Real-time location tracking of the driver is shown to the passenger during the trip
- Surge pricing is applied dynamically based on local supply/demand imbalance
- Fare estimation is shown to the passenger before confirming the booking
- Trip state machine: REQUESTED → DRIVER_ASSIGNED → DRIVER_ARRIVED → IN_PROGRESS → COMPLETED / CANCELLED
- Rating and feedback after trip completion
Non-Functional
- 25M trips/day ≈ 290 trips/sec at average, 1,000+ trips/sec at peak
- 5M drivers online simultaneously, each sending location updates every 4 seconds
- Driver matching must complete in < 2 seconds from ride request
- Location update ingestion: 5M × (60/4) = 75M updates/min ≈ 1.25M updates/sec
- 99.99% availability — failed matches directly lose revenue
- Horizontal scalability across multiple geographic regions
Capacity Estimation
| Trips / day | 25M ≈ 290 / sec (peak ~1,000 / sec) |
| Active drivers | 5M simultaneously online |
| Location updates | 5M drivers × 15/min = 75M/min ≈ 1.25M/sec |
| Location record size | ~50 bytes (driverId, lat, lng, timestamp, status) |
| Location write throughput | 1.25M × 50B ≈ 62.5 MB/sec raw ingestion |
| Trip record size | ~1 KB with route + fare metadata |
| Trip storage / year | 25M × 365 × 1KB ≈ 9 TB / year |
High-Level Components
API Gateway / Load Balancer
Routes HTTP requests and manages WebSocket upgrade handshakes. Handles JWT authentication, rate limiting, and geographic routing to the nearest regional cluster.
Location Update Service
Receives driver location pings every 4 seconds via WebSocket or HTTP. Writes to a geospatial index (Redis + geohash) for real-time proximity queries, and publishes events to Kafka for analytics and ETA recalculation.
Matching Service
On ride request, queries the geospatial index for available drivers within expanding radius rings (500m → 1km → 2km). Scores candidates by proximity, acceptance rate, and rating. Sends offer to the top-ranked driver. If declined or timed out, moves to the next candidate.
Trip Service
Owns the trip state machine. Persists all trip records to PostgreSQL. Emits state change events to Kafka consumed by notification, billing, and analytics services.
Surge Pricing Service
Monitors the ratio of active ride requests to available drivers per geohash cell every 60 seconds. When demand exceeds supply by a configurable threshold, a surge multiplier is applied. Multipliers are stored in Redis and served to the Fare Estimation API.
Geospatial Index (Redis)
Stores current driver positions using Redis GEO commands (backed by a sorted set with geohash scores). Supports GEORADIUS queries for O(log N + M) proximity lookups. Also maintains driver status (AVAILABLE / BUSY / OFFLINE) as a separate hash.
WebSocket Gateway
Maintains persistent connections with passengers and drivers. Pushes real-time events: driver location during trip, ride state transitions, ETA updates, and surge price changes. Horizontally scaled — clients reconnect on node failure.
Notification Service
Sends push notifications (FCM/APNs), SMS, and in-app alerts for key trip events. Consumes from Kafka so it is fully decoupled from the critical booking path.
ETA Service
Computes estimated arrival times using road network graph (OSRM or Google Maps API). Called during matching to rank candidates and periodically during the trip to update the passenger.
Architecture Diagram
Deep Dives
Geospatial Indexing with Geohash
Tracking 5M moving drivers and querying "drivers within 1km of this point" in milliseconds requires a geospatial index, not a brute-force table scan.
Geohash encoding: A geohash encodes a (lat, lng) pair into a short alphanumeric string by recursively bisecting the Earth's surface into a grid. Each additional character halves the cell size. At 6 characters, cell size ≈ 1.2 km × 0.6 km. At 7 characters, ≈ 150m × 150m.
Why geohash is useful for proximity: Strings with the same prefix are geographically adjacent (with a known edge-case: cells on a geohash boundary may have very different prefixes). A proximity query becomes: "find all drivers whose geohash starts with the same 5-character prefix as the pickup point, plus the 8 neighbouring cells."
Redis GEO: Redis implements geohash internally as a sorted set where the score is the 52-bit geohash. `GEOADD drivers lng lat driverId` stores a driver. `GEORADIUS drivers lng lat 1 km` returns all drivers within 1 km, optionally sorted by distance — O(log N + M).
H3 (Uber's hexagonal grid): H3 divides the Earth into hexagons at multiple resolution levels. Hexagons have equal area and equal-distance neighbours (unlike rectangular grid cells), making supply/demand calculations more uniform. Used for surge pricing zone calculation.
Java — Geohash encoding of (lat, lng) coordinates
// Geohash encode (lat, lng) to a given precision (1–12 chars)
public class Geohash {
private static final String BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
public static String encode(double lat, double lng, int precision) {
double[] latRange = {-90.0, 90.0};
double[] lngRange = {-180.0, 180.0};
StringBuilder hash = new StringBuilder();
int bits = 0, bitsTotal = 0, hashValue = 0;
boolean isLng = true;
while (hash.length() < precision) {
double[] range = isLng ? lngRange : latRange;
double mid = (range[0] + range[1]) / 2;
double value = isLng ? lng : lat;
if (value >= mid) {
hashValue = (hashValue << 1) | 1;
range[0] = mid;
} else {
hashValue <<= 1;
range[1] = mid;
}
isLng = !isLng;
if (++bits == 5) {
hash.append(BASE32.charAt(hashValue));
bits = 0;
hashValue = 0;
}
}
return hash.toString();
}
// encode(12.9716, 77.5946, 6) → "tdnu2g" (Bengaluru, ~1.2km cell)
// encode(12.9716, 77.5946, 7) → "tdnu2gk" (~150m cell)
}Driver Location Update Pipeline
At 1.25M location updates/sec, the ingestion path must be designed for high throughput with minimal write amplification.
Flow: 1. Driver app sends GPS ping via WebSocket every 4 seconds. 2. Location Update Service receives the event, validates it, and does two things in parallel: (a) updates the driver's position in Redis GEO with `GEOADD`, and (b) publishes an event to Kafka for downstream consumers (analytics, ETA recalculation, trip tracking). 3. Redis GEO `GEOADD` is an O(log N) sorted set update — at 1.25M/sec this requires a Redis cluster. Shard by geographic region (e.g. city or geohash prefix at length 2).
Why not write to a database directly? A relational database at 1.25M writes/sec would be crushed. Redis can handle millions of ops/sec in-memory. The persistent record of trip locations is written by the Trip Service at coarser granularity (one record per trip waypoint, not every ping).
Driver status transitions: GEOADD alone is not enough — the Matching Service must only query AVAILABLE drivers. Maintain a separate Redis Set per status: `drivers:available:{geohashPrefix}`. When a driver accepts a trip, move them from available to busy atomically using a Redis transaction.
Java — Location update ingestion + Redis GEO proximity query
@Service
public class LocationUpdateService {
private final RedisTemplate<String, String> redis;
private final KafkaTemplate<String, DriverLocationEvent> kafka;
// Called for every incoming GPS ping
public void handleLocationUpdate(String driverId, double lat, double lng) {
// 1. Update geospatial index — O(log N)
redis.opsForGeo().add(
"drivers:geo",
new Point(lng, lat), // Redis GEO uses (lng, lat) order
driverId
);
// 2. Refresh driver's last-seen TTL (expire if app dies)
redis.expire("driver:active:" + driverId, Duration.ofSeconds(30));
// 3. Async Kafka publish for downstream consumers (ETA, analytics)
kafka.send("driver-locations", driverId,
new DriverLocationEvent(driverId, lat, lng, Instant.now())
);
}
// Find available drivers within radiusKm of (lat, lng)
public List<String> findNearbyDrivers(double lat, double lng, double radiusKm) {
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
redis.opsForGeo().radius(
"drivers:geo",
new Circle(new Point(lng, lat), new Distance(radiusKm, Metrics.KILOMETERS)),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance()
.sortAscending()
.limit(50)
);
return results.getContent().stream()
.filter(r -> isDriverAvailable(r.getContent().getName()))
.map(r -> r.getContent().getName())
.collect(Collectors.toList());
}
private boolean isDriverAvailable(String driverId) {
return Boolean.TRUE.equals(redis.hasKey("driver:active:" + driverId))
&& "AVAILABLE".equals(redis.opsForValue().get("driver:status:" + driverId));
}
}Matching Service
The matching service must select the best driver for a ride request within 2 seconds and handle the offer/accept/decline lifecycle.
Algorithm: 1. Query Redis GEO for available drivers within 500m → 1km → 2km (expanding rings until candidates are found). 2. Score each candidate: - Proximity (primary): distance to pickup - Acceptance rate: penalise drivers who frequently decline - Rating: prefer higher-rated drivers - Consecutive idle time: prefer drivers who have been waiting longer (fairness) 3. Send offer to the top-ranked driver with a 15-second acceptance window. 4. If declined or timeout: mark driver as "offered-declined" for this trip (exclude from retry), and send offer to the next candidate. 5. After 3 rounds with no acceptance, escalate pickup radius and retry.
Concurrency problem: Two passengers near the same driver could both trigger matching concurrently. Use a Redis distributed lock (SET NX EX) to "reserve" a driver for a single active offer at a time. The lock is held for 15 seconds (the offer window) and released on accept or timeout.
Idempotency: The matching service may crash mid-flight. Store offer state (driverId, passengerId, offeredAt, status) in a database so restarts can recover in-flight offers.
Java — Matching service with distributed driver offer lock
@Service
public class MatchingService {
private static final int OFFER_TIMEOUT_SECS = 15;
private final LocationUpdateService locationService;
private final RedisTemplate<String, String> redis;
private final TripRepository tripRepository;
public Optional<String> matchDriver(String tripId, double pickupLat, double pickupLng) {
double[] radii = {0.5, 1.0, 2.0, 5.0};
for (double radiusKm : radii) {
List<String> candidates = locationService.findNearbyDrivers(pickupLat, pickupLng, radiusKm);
for (String driverId : candidates) {
// Try to acquire exclusive offer lock for this driver
String lockKey = "offer:lock:" + driverId;
Boolean acquired = redis.opsForValue()
.setIfAbsent(lockKey, tripId, Duration.ofSeconds(OFFER_TIMEOUT_SECS));
if (Boolean.TRUE.equals(acquired)) {
// Mark driver status as OFFERED
redis.opsForValue().set("driver:status:" + driverId, "OFFERED",
Duration.ofSeconds(OFFER_TIMEOUT_SECS));
// Persist offer record for crash recovery
tripRepository.saveOffer(tripId, driverId, Instant.now());
// Push offer to driver via WebSocket (async)
pushOfferToDriver(driverId, tripId, pickupLat, pickupLng);
// Wait for acceptance (blocking poll with timeout)
if (waitForAcceptance(driverId, tripId, OFFER_TIMEOUT_SECS)) {
return Optional.of(driverId);
}
// Driver declined or timed out — release lock, try next
redis.delete(lockKey);
redis.opsForValue().set("driver:status:" + driverId, "AVAILABLE");
}
}
}
return Optional.empty(); // No driver found
}
private boolean waitForAcceptance(String driverId, String tripId, int timeoutSecs) {
String acceptKey = "offer:accepted:" + tripId + ":" + driverId;
long deadline = System.currentTimeMillis() + (timeoutSecs * 1000L);
while (System.currentTimeMillis() < deadline) {
if (Boolean.TRUE.equals(redis.hasKey(acceptKey))) return true;
LockSupport.parkNanos(Duration.ofMillis(250).toNanos());
}
return false;
}
private void pushOfferToDriver(String driverId, String tripId, double lat, double lng) {
// WebSocket push to driver's active connection (implementation omitted)
}
}Surge Pricing
Surge pricing increases fares dynamically when demand exceeds supply, incentivising more drivers to come online and discouraging marginal riders.
Input signals: For each geohash cell (or H3 hex) at resolution level 6 (≈1.2km), compute every 60 seconds: - `demand`: number of ride requests in the last 5 minutes within the cell - `supply`: number of AVAILABLE drivers within the cell - `ratio = demand / max(supply, 1)`
Surge multiplier mapping: ``` ratio < 1.5 → 1.0x (no surge) ratio 1.5–2 → 1.2x ratio 2–3 → 1.5x ratio 3–4 → 2.0x ratio > 4 → 2.5x (capped) ```
Storing and serving: Write the multiplier map (`{geohash6 → multiplier}`) to Redis as a hash. The Fare Estimation API reads the multiplier for the pickup's geohash and multiplies the base fare. The WebSocket gateway pushes surge zone GeoJSON polygons to the passenger app UI every 60 seconds.
Anti-gaming: Prevent the multiplier from jumping from 1.0x to 2.5x in one step — apply a maximum step-up of 0.3x per 60-second window to smooth the experience.
Java — Surge pricing recalculation with smoothing and Redis storage
@Scheduled(fixedDelay = 60_000)
public void recalculateSurge() {
// Fetch all geohash-6 cells that have had activity in last 10 minutes
Set<String> activeCells = supplyDemandStore.getActiveCells();
for (String cell : activeCells) {
int demand = supplyDemandStore.getDemand(cell); // ride requests, last 5 min
int supply = supplyDemandStore.getSupply(cell); // available drivers in cell
double ratio = (double) demand / Math.max(supply, 1);
double newMultiplier = toMultiplier(ratio);
// Smooth: cap step-up at 0.3x per cycle
double current = getSurgeMultiplier(cell);
double smoothed = Math.min(newMultiplier, current + 0.3);
// Store in Redis hash — key: "surge", field: geohash6, value: multiplier
redis.opsForHash().put("surge", cell, String.valueOf(smoothed));
}
}
private double toMultiplier(double ratio) {
if (ratio < 1.5) return 1.0;
if (ratio < 2.0) return 1.2;
if (ratio < 3.0) return 1.5;
if (ratio < 4.0) return 2.0;
return 2.5; // hard cap
}
public double getSurgeMultiplier(String geohash6) {
Object val = redis.opsForHash().get("surge", geohash6);
return val != null ? Double.parseDouble(val.toString()) : 1.0;
}
// Fare Estimation API
public FareEstimate estimate(double pickupLat, double pickupLng,
double dropLat, double dropLng) {
String cell = Geohash.encode(pickupLat, pickupLng, 6);
double surge = getSurgeMultiplier(cell);
double baseFare = calculateBaseFare(pickupLat, pickupLng, dropLat, dropLng);
return new FareEstimate(baseFare * surge, surge);
}Key Trade-offs
Geohash vs H3 for spatial indexing
Redis natively supports geohash via GEORADIUS, making proximity queries trivial. H3 hexagons have equal-area cells ideal for uniform supply/demand analysis but require more infrastructure. Use the right tool for each workload.
WebSocket vs HTTP polling for driver location
5M drivers sending updates every 4 seconds = 1.25M/sec. HTTP polling adds per-request overhead (headers, connection setup) that is prohibitive at this rate. WebSocket is a persistent connection — the overhead is paid once.
Redis GEO vs PostGIS for driver location store
PostGIS is excellent for complex geospatial queries on persistent data. Driver locations are ephemeral and change every 4 seconds. Redis GEO handles millions of point updates/sec in-memory with sub-millisecond GEORADIUS queries. PostGIS would become a write bottleneck.
Per-driver offer lock vs queue-based matching
A queue-based approach serialises all matching for a region through a single consumer, creating a bottleneck. Per-driver locks are fine-grained — many matches can proceed in parallel across different drivers, with only conflict on the same driver.
Synchronous fare deduction vs async billing
Charging the passenger at trip completion involves payment gateway calls that can be slow or fail. Decoupling via Kafka ensures trip completion is acknowledged instantly, and billing retries independently without affecting the user-facing response.
Interview Tips
- 1Lead with scale numbers. 5M drivers × 15 pings/min = 1.25M writes/sec is the hardest problem — state it upfront and design the location update pipeline around it.
- 2Interviewers love the geohash question. Explain that a 6-character geohash is ~1.2km × 0.6km and proximity = same prefix + 8 neighbours. Mention the edge case where neighbouring cells can have different prefixes.
- 3Draw the trip state machine explicitly: REQUESTED → DRIVER_ASSIGNED → DRIVER_ARRIVED → IN_PROGRESS → COMPLETED. Interviewers expect you to handle the state transitions and what happens at each.
- 4The distributed offer lock (Redis SET NX EX) is a key insight. Without it, two passengers could be matched to the same driver simultaneously.
- 5Surge pricing: explain that the multiplier is a function of demand/supply ratio per geographic cell, stored in Redis, and smoothed to avoid jarring jumps.
- 6When asked about ETA, mention that road network graph queries (OSRM) are expensive — pre-compute ETAs for the top N candidates rather than all drivers in radius.
- 7Always mention the decoupling of analytics, notifications, and billing via Kafka so they never touch the latency-critical matching path.
Discussion
Discussion
Sign in to join the discussion.