Loading content...
When a rider opens Uber and sees "3 min away," that simple number carries enormous weight. It's a promise—a commitment that influences whether they request a ride, wait longer, or switch to alternatives.
The stakes are high:
The challenge is immense:
ETA calculation requires solving a routing problem on a graph with:
Building an accurate, fast, scalable ETA system is one of the most technically demanding challenges in ride-sharing infrastructure.
By the end of this page, you will understand how ETA systems work—from graph-based routing algorithms to ML-enhanced travel time prediction. You'll learn about road network representation, real-time traffic integration, and the architectural patterns that enable sub-second route calculation at global scale.
Ride-sharing platforms compute different types of ETAs for different purposes. Understanding these distinctions is essential for system design.
The time for an assigned driver to reach the rider's pickup location.
Characteristics:
The expected duration of the trip itself.
Characteristics:
Real-time updated estimate during an active trip.
Characteristics:
| ETA Type | Latency Requirement | Update Frequency | Accuracy Target |
|---|---|---|---|
| Pickup ETA (matching) | < 100ms | Once at match time | ± 60 seconds |
| Pickup ETA (post-match) | < 200ms | Every 30 seconds | ± 30 seconds |
| Trip ETA (fare estimate) | < 300ms | Once at request | ± 15% of trip time |
| Arrival ETA (in-trip) | < 500ms | Every 10-30 seconds | Converges to actual |
| Batch ETAs (supply analysis) | < 2 seconds | Periodic (minutes) | ± 2 minutes acceptable |
Upfront pricing (showing a guaranteed fare before trip confirmation) requires accurate trip ETA. The fare formula is typically: Base + (Time × per-minute rate) + (Distance × per-km rate) + Surge. A 20% ETA error directly translates to pricing errors that the platform must absorb.
ETA calculation fundamentally starts with representing the road network as a directed graph.
Nodes represent:
Edges represent:
1234567891011121314151617181920212223242526272829303132333435363738
// Node: represents a point in the road networkclass Node: id: Int64 // Unique identifier latitude: Float64 longitude: Float64 type: Enum // INTERSECTION, DEAD_END, GRADE_SEPARATION // Edge: represents a road segmentclass Edge: id: Int64 source_node_id: Int64 target_node_id: Int64 // Static attributes (rarely change) length_meters: Float32 road_class: Enum // HIGHWAY, PRIMARY, SECONDARY, RESIDENTIAL, etc. speed_limit_kph: Float32 num_lanes: Int8 is_toll_road: Boolean is_tunnel: Boolean surface_type: Enum // PAVED, GRAVEL, etc. // Time-varying attributes typical_speed_kph: Map<HourOfWeek, Float32> // 168 hours × speed // Geometry for display polyline: List<LatLng> // Shape of the road segment // Turn restriction: some left turns prohibited, etc.class TurnRestriction: from_edge_id: Int64 to_edge_id: Int64 restriction_type: Enum // NO_LEFT_TURN, NO_U_TURN, ONLY_STRAIGHT time_window: Optional<TimeRange> // Some restrictions time-based // Graph summary statistics// Typical city: 50,000-500,000 nodes, 100,000-1,000,000 edges// Global: 500,000,000+ nodes, 1,000,000,000+ edgesThe critical insight: edges should be weighted by travel time, not distance.
| Route | Distance | Travel Time (no traffic) | Travel Time (rush hour) |
|---|---|---|---|
| Highway A | 15 km | 10 min @ 90 km/h | 25 min @ 36 km/h |
| Surface streets B | 10 km | 12 min @ 50 km/h | 15 min @ 40 km/h |
Shortest distance (Route B) ≠ Shortest time. And shortest time varies by hour.
Solution: Time-dependent edge weights
Edge weight = f(edge, departure_time)
For a 168-hour week (24 hours × 7 days), pre-compute expected travel time per edge per hour.
Most routing engines (including OSRM, Valhalla) use OpenStreetMap (OSM) as their base map. OSM provides crowdsourced road geometry and attributes. Commercial map providers (HERE, TomTom, Google) add proprietary traffic data and more frequent updates.
Finding the shortest path between two points is a classic graph problem. But at ride-sharing scale, naive algorithms are too slow.
The foundational shortest-path algorithm.
123456789101112131415161718192021222324252627282930313233343536373839
function dijkstra(graph, source, target): // Distance to all nodes, initially infinite dist = Map<Node, Float>.defaultValue(INFINITY) prev = Map<Node, Node>.empty() // Priority queue of (distance, node) queue = PriorityQueue<(Float, Node)>.minHeap() dist[source] = 0 queue.push((0, source)) visited = Set<Node>() while not queue.isEmpty(): (d, current) = queue.pop() if current in visited: continue visited.add(current) // Early termination when target found if current == target: return reconstructPath(prev, target), dist[target] for edge in graph.outgoingEdges(current): neighbor = edge.target edgeWeight = getEdgeWeight(edge, departureTime) newDist = dist[current] + edgeWeight if newDist < dist[neighbor]: dist[neighbor] = newDist prev[neighbor] = current queue.push((newDist, neighbor)) return null, INFINITY // No path exists // Complexity: O((V + E) log V) with binary heap// For city with 500K nodes, 1M edges: ~10-50 ms per query// 6,000 queries/second would require 60-300 CPU cores for Dijkstra aloneA* improves on Dijkstra by using a heuristic to guide the search toward the target.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
function aStar(graph, source, target): // f(n) = g(n) + h(n) // g(n) = actual cost from source to n // h(n) = heuristic estimate from n to target gScore = Map<Node, Float>.defaultValue(INFINITY) fScore = Map<Node, Float>.defaultValue(INFINITY) prev = Map<Node, Node>.empty() gScore[source] = 0 fScore[source] = heuristic(source, target) // Priority queue ordered by f-score (g + h) queue = PriorityQueue<(Float, Node)>.minHeap() queue.push((fScore[source], source)) visited = Set<Node>() while not queue.isEmpty(): (_, current) = queue.pop() if current in visited: continue visited.add(current) if current == target: return reconstructPath(prev, target), gScore[target] for edge in graph.outgoingEdges(current): neighbor = edge.target tentativeG = gScore[current] + getEdgeWeight(edge, departureTime) if tentativeG < gScore[neighbor]: prev[neighbor] = current gScore[neighbor] = tentativeG fScore[neighbor] = tentativeG + heuristic(neighbor, target) queue.push((fScore[neighbor], neighbor)) return null, INFINITY function heuristic(node, target): // Euclidean distance / max road speed // Must be admissible: never overestimate the actual cost straightLineDistance = haversine(node, target) maxSpeedKph = 130 // Fastest possible road speed return straightLineDistance / (maxSpeedKph / 3.6) // Time in seconds // A* typically explores 30-70% fewer nodes than Dijkstra// Still O(V log V) worst case, but much faster in practiceFor production ETA services handling thousands of queries per second, even A* is too slow. Contraction Hierarchies (CH) enables sub-millisecond queries through preprocessing.
Key Insight: Most shortest paths go through major roads. A path from suburb A to suburb B almost certainly uses highways, not residential streets.
Preprocessing (once, offline):
Query (online, fast):
| Algorithm | Query Time | Preprocessing | Memory Overhead | Traffic Updates |
|---|---|---|---|---|
| Dijkstra | 10-50 ms | None | Low | Trivial (re-weight edges) |
| A* | 3-20 ms | None | Low | Trivial |
| Contraction Hierarchies | < 1 ms | Minutes to hours | 2-5x graph size | Must recompute shortcuts |
| Customizable CH | 1-5 ms | Minutes + seconds | 2-5x | Fast metric update possible |
Standard CH assumes static edge weights. But traffic changes constantly! Customizable Contraction Hierarchies (CCH) separate the graph structure (preprocessed once) from edge weights (updated live). This allows sub-second metric updates while maintaining fast queries.
Static routing (historical speed profiles) isn't enough. Real-time traffic—accidents, weather, events—dramatically affects actual travel times.
| Source | Coverage | Latency | Quality |
|---|---|---|---|
| Uber/Lyft driver GPS | Excellent on served roads | Real-time (seconds) | Very high (actual trips) |
| Smartphone GPS (Google/Apple) | Excellent urban | Real-time | High (large sample) |
| Connected vehicles (OEM) | Growing | Near real-time | High |
| Traffic cameras/sensors | Limited to instrumented roads | Real-time | High accuracy |
| Historical patterns | All roads | N/A (not real-time) | Baseline only |
Uber has millions of active drivers whose GPS traces provide real-time traffic speeds:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Stream processor consuming driver location updates class TrafficSpeedAggregator: // Store recent traversals per road segment // segmentId → list of (timestamp, traversal_time_seconds) recentTraversals = SlidingWindowMap<SegmentId, List<Traversal>> function onDriverLocationUpdate(driverId, location, timestamp): // Map-match to road segment currentSegment = mapMatch(location) // Get previous location for this driver prevLocation = driverState[driverId].lastLocation prevSegment = driverState[driverId].lastSegment // Did driver complete traversal of a segment? if prevSegment != currentSegment and prevSegment != null: traversalTime = timestamp - driverState[driverId].segmentEntryTime segmentLength = getSegmentLength(prevSegment) // Compute speed speed_mps = segmentLength / traversalTime speed_kph = speed_mps * 3.6 // Filter outliers (GPS errors, wrong map-matching) if speed_kph > 5 and speed_kph < 200: // Reasonable range recentTraversals[prevSegment].append({ timestamp: timestamp, speed: speed_kph }) // Update driver state if prevSegment != currentSegment: driverState[driverId].segmentEntryTime = timestamp driverState[driverId].lastLocation = location driverState[driverId].lastSegment = currentSegment @scheduled(every=60.seconds) function computeCurrentSpeeds(): currentSpeeds = {} for segmentId, traversals in recentTraversals: // Keep only last 10 minutes of data recentTraversals[segmentId] = traversals.filter( t => t.timestamp > now() - 10.minutes ) if traversals.length >= MIN_SAMPLES: // e.g., 3 samples // Median speed (robust to outliers) speeds = traversals.map(t => t.speed) currentSpeeds[segmentId] = median(speeds) else: // Fall back to historical pattern currentSpeeds[segmentId] = getHistoricalSpeed(segmentId, now()) // Publish to routing engine routingEngine.updateEdgeWeights(currentSpeeds)Not every road segment has recent driver traversals. Strategies for sparse data:
A new city launch has no driver GPS data. ETAs must rely entirely on historical patterns from map providers or third-party traffic data until sufficient drivers are on the road. First-week ETA accuracy is typically 20-30% worse than mature markets.
Pure routing algorithms compute a theoretical optimal path. But real-world travel times include factors routing can't capture:
Machine learning models can learn these patterns from historical trip data.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
class ETAPredictionModel: """ Gradient Boosted Decision Tree (GBDT) or Deep Learning model that predicts trip duration given routing estimate + contextual features. """ def extractFeatures(self, routingResult, tripRequest, context): features = {} # Routing-based features features["routing_eta_seconds"] = routingResult.eta features["routing_distance_meters"] = routingResult.distance features["num_turns"] = routingResult.turnCount features["highway_fraction"] = routingResult.highwayDistance / routingResult.distance # Temporal features features["hour_of_day"] = tripRequest.requestTime.hour features["day_of_week"] = tripRequest.requestTime.dayOfWeek features["is_holiday"] = isHoliday(tripRequest.requestTime) features["minutes_since_midnight"] = tripRequest.requestTime.minuteOfDay # Geographic features features["pickup_h3_cell"] = h3.geoToH3(tripRequest.pickup, resolution=7) features["dropoff_h3_cell"] = h3.geoToH3(tripRequest.dropoff, resolution=7) features["pickup_poi_type"] = getNearestPOIType(tripRequest.pickup) # AIRPORT, HOTEL, etc. features["dropoff_poi_type"] = getNearestPOIType(tripRequest.dropoff) # Weather features features["precipitation"] = getWeather(tripRequest.pickup).precipitation features["visibility_km"] = getWeather(tripRequest.pickup).visibility # Traffic features (aggregated) features["current_city_congestion_index"] = getCityCongestionIndex(tripRequest.city) features["route_congestion_level"] = estimateRouteCongestion(routingResult.edges) # Historical features features["historical_median_for_od_pair"] = getHistoricalMedian( tripRequest.pickup_zone, tripRequest.dropoff_zone, tripRequest.requestTime.hour ) features["recent_similar_trips_median"] = getRecentSimilarTripsMedian( tripRequest, window=60.minutes, sampleSize=50 ) return features def predict(self, features): # Model outputs a multiplicative adjustment factor # eta_predicted = routing_eta × adjustment_factor adjustmentFactor = self.model.predict(features) # Typical range: 0.8 to 1.5 adjustmentFactor = clamp(adjustmentFactor, 0.6, 2.0) predictedETA = features["routing_eta_seconds"] * adjustmentFactor return predictedETA| Metric | Definition | Target |
|---|---|---|
| MAPE | Mean Absolute Percentage Error | < 15% |
| MAE | Mean Absolute Error (seconds) | < 90 seconds for pickup ETA |
| P50 Error | Median error magnitude | < 10% |
| P90 Error | 90th percentile error | < 25% |
| Bias | Avg(predicted - actual) | ± 30 seconds (minimal over/under) |
Production systems often combine routing-based ETAs with ML predictions. A simple approach: final_eta = α × routing_eta + (1-α) × ml_eta, where α is tuned based on ML model's historical accuracy. This provides graceful degradation if the ML model has issues.
Serving 6,000+ ETA queries per second with sub-200ms latency requires careful architectural design.
1. Geographic Sharding of Routing Engines
The global road graph is too large for a single machine. Partition by region:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
class ETAService: routingCache = RoutingCache(ttl=60.seconds, maxSize=1M) regionRouters = Map<Region, RoutingClient>() mlModel = MLModelClient() async function getETA(origin: LatLng, destination: LatLng, options: ETAOptions): # Check cache first cacheKey = computeCacheKey(origin, destination, options) cached = routingCache.get(cacheKey) if cached and not options.bypassCache: return cached # Determine region(s) for this route regions = getRegionsForRoute(origin, destination) if regions.length == 1: # Single-region route (most common) routingResult = await regionRouters[regions[0]].route(origin, destination) else: # Cross-region route - stitch together routingResult = await computeCrossRegionRoute(origin, destination, regions) # ML enhancement if options.useMlEnhancement: features = extractFeatures(routingResult, options.context) mlAdjustment = await mlModel.predict(features) eta = routingResult.eta * mlAdjustment else: eta = routingResult.eta result = { eta_seconds: eta, distance_meters: routingResult.distance, route_polyline: routingResult.polyline, computed_at: now() } # Cache result routingCache.set(cacheKey, result, ttl=60.seconds) return result function computeCacheKey(origin, destination, options): # Snap to ~100m grid for cache hit rate originSnapped = snapToGrid(origin, gridSize=100) destSnapped = snapToGrid(destination, gridSize=100) return hash( originSnapped.lat, originSnapped.lng, destSnapped.lat, destSnapped.lng, options.vehicleType, floor(now().minuteOfHour / 5) # 5-minute time buckets )2. Aggressive Caching
| Cache Level | Key Strategy | TTL | Hit Rate |
|---|---|---|---|
| L1 (in-process) | Exact origin/dest | 30 sec | 5-10% |
| L2 (Redis) | Snapped to 100m grid | 60 sec | 30-50% |
| L3 (precomputed) | Zone-to-zone matrices | 5 min | 80%+ for fare estimates |
3. Precomputed Zone Matrices
For fare estimation, exact routes aren't needed. Precompute average ETAs between zone pairs:
OSRM (Open Source Routing Machine), Valhalla, and GraphHopper are popular open-source routing engines. They implement Contraction Hierarchies and can serve thousands of QPS per instance. Commercial options like Google Directions API offer convenience but add per-query costs.
ETA calculation is a core capability that impacts every user interaction in ride-sharing. Let's consolidate the key learnings:
| Decision | Recommendation | Tradeoff |
|---|---|---|
| Routing algorithm | Contraction Hierarchies (or CCH) | Sub-ms queries but preprocessing required |
| Traffic update frequency | Every 60 seconds per segment | Faster = more accurate but higher compute cost |
| Cache TTL | 60 seconds (routing), 5 min (zone matrix) | Shorter = fresher but lower hit rate |
| ML vs routing blend | 70% routing + 30% ML adjustment | Varies by market maturity and model quality |
| Grid snap resolution | 100 meters | Coarser = more hits but less precision |
With ETA calculation understood, we now turn to the final piece: Trip Management—the state machine that orchestrates the complete ride lifecycle from request to payment. You'll learn about event sourcing, payment orchestration, failure handling, and the patterns that ensure reliable trip completion even when components fail.