Most articles about ride-hailing or rental apps focus on the UI: booking screens, driver cards, payment sheets. The part that actually determines whether the platform survives its first real traffic spike is invisible to users: the dispatch and matching engine that decides, in under a second, which driver gets which request.
This piece breaks down how we approached building that engine for an on-demand mobility platform, the trade-offs we ran into, and what we’d do differently if starting again.
Why Dispatch Is the Hardest Part of the System
On paper, matching a rider to a driver sounds like a simple nearest-neighbour problem. In practice, it’s a real-time optimization problem running against constantly moving inputs.
A dispatch engine has to account for:
- Driver location, updated every 2–4 seconds from potentially thousands of devices
- Vehicle type and category (sedan, SUV, bike, EV) matched against rider preference
- Driver rating and acceptance history
- Current demand density, since matching the “closest” driver isn’t always optimal if it strands demand elsewhere
- Network jitter and GPS drift, which make raw coordinates noisier than they look
Get any of these wrong and the symptom shows up as long wait times, driver no-shows, or riders getting matched with a driver who’s technically closer in a straight line but ten minutes away by actual road.
Architecture Overview
We settled on a layered architecture, separating location ingestion from matching logic from the API surface that clients talk to.
[Rider App] –ride request–> [Matching Service] <———–+ | v [Dispatch Decision] –> [Notification Service] –> Driver App
Ingestion Service
Every driver app pushes a location update on a short interval, typically 2–4 seconds while online. Rather than writing every ping straight to a relational database, we pushed updates into an in-memory geospatial store (Redis with geohash-based sorted sets) and only persisted a lower-frequency snapshot to durable storage for analytics and auditing.
This mattered more than we expected. Writing every raw ping to Postgres under real driver-fleet volume created write contention that showed up as latency spikes exactly during peak demand, which is the worst possible time for it to happen.
Geo Index
Geohashing lets you turn a latitude/longitude pair into a string prefix where nearby locations share prefixes. That makes “find drivers within roughly 2km” a fast prefix-range query instead of a full table scan with distance calculations on every row.
A simplified version of the lookup looked like this:
def find_nearby_drivers(geo_index, rider_geohash, precision=6, radius_km=2):
neighbors = geohash_neighbors(rider_geohash, precision)
candidates = []
for cell in neighbors:
candidates.extend(geo_index.get(cell, []))
return [
d for d in candidates
if haversine_distance(d.location, rider_geohash) <= radius_km
]
The geohash lookup narrows the candidate pool quickly; the haversine check afterward filters out false positives near cell boundaries. This two-step approach kept lookup latency well under 100ms even with several thousand active drivers in a city.
Matching Service
Once we have a candidate pool, matching isn’t just “pick the closest one.” We scored candidates on a weighted combination of:
- Estimated time to pickup (not straight-line distance actual routed ETA)
- Driver acceptance rate over their last N requests
- Vehicle category match
- Idle time (to avoid always routing demand to the same few drivers while others sit idle)
This is where a lot of teams underinvest. It’s tempting to ship “nearest driver wins” for the MVP and revisit later. We did exactly that, and it caused a specific, painful problem: drivers parked near high-demand zones got flooded with requests while drivers slightly further away sat idle for long stretches, which drove those drivers to log off entirely. Idle-time weighting fixed this, but only after we saw driver churn data and traced it back to the matching logic.
Dispatch Decision and Notification
Once a driver is selected, the request goes out with a short acceptance window (we used 12 seconds). If it times out or gets declined, the engine falls back to the next-ranked candidate rather than re-running the full matching query, which keeps total assignment time predictable even under fallback scenarios.
Real-Time Delivery: WebSockets vs. Polling
We initially built the driver-side notification layer on short-interval polling because it was faster to ship. It worked fine in testing with a handful of devices and fell over almost immediately at moderate scale polling every driver app every few seconds multiplies request volume linearly with fleet size, and that cost scales badly.
We moved to a WebSocket-based push model for ride requests and location broadcasts, keeping REST endpoints for anything that isn’t time-sensitive (ride history, profile updates, payment records). This cut notification latency from several seconds down to under 500ms in most cases, and meaningfully reduced backend load per active driver.
The trade-off is operational complexity. WebSocket connections need their own scaling story sticky sessions or a pub/sub layer (we used Redis pub/sub) so that a driver’s assigned server instance doesn’t become a single point of failure. If you’re building this in-house, budget real engineering time for connection handling, not just the happy-path “send a message” logic.
Handling GPS Drift and Bad Data
Raw GPS coordinates from mobile devices are noisier than most teams expect, especially in dense urban areas with tall buildings. We saw location pings that placed a driver on the wrong side of a highway or inside a building that clearly wasn’t a road.
Two things helped:
- Snapping to the road network. Running raw coordinates through a map-matching step before using them in ETA calculations removed a lot of the visible jitter riders were noticing on the tracking screen.
- Outlier rejection. If a new ping implied a speed that’s physically implausible (say, 150 km/h in city traffic), we discarded it and waited for the next update rather than trusting it outright.
Neither of these is exotic, but skipping them shows up directly as “the map looks broken” complaints from users, which erodes trust fast even when the underlying matching logic is working correctly.
Scaling Considerations
A few decisions made scaling less painful later:
- Sharding the geo index by city or region rather than running one global index. Most ride-hailing and rental demand is inherently regional, so there’s little reason to pay cross-region query costs for a match that will always resolve locally.
- Decoupling matching from notification. Keeping these as separate services meant we could scale the notification layer (which is I/O-bound and connection-heavy) independently from the matching layer (which is more CPU and query-bound).
- Idempotent dispatch decisions. Network retries are inevitable. Making sure a duplicate dispatch request couldn’t double-assign a driver saved us from a genuinely nasty class of bug during a load test.
Where This Applies Beyond Ride-Hailing
The same architecture pattern shows up almost unchanged in other on-demand mobility categories. Teams building out a full ride-hailing app development stack tend to hit this exact set of problems: geospatial indexing, real-time push, and matching logic that has to account for more than raw distance.
Self-drive rental platforms need a lighter version of the same ideas, even without live per-second driver tracking. Availability indexing by location and time window, real-time fleet status, and reliable push notifications for booking confirmations all borrow from the same core patterns used in dispatch systems, which is worth knowing if your roadmap includes expanding from ride-hailing into adjacent models like car rental app development down the line.
Lessons Learned
- If we were starting this build again, a few things we’d do differently from day one:
Instrument matching decisions from the start. We didn’t log why a specific driver was chosen over others until after the idle-time problem surfaced. Having that data from day one would have caught it weeks earlier. - Treat the notification layer as a first-class system, not an afterthought. Polling felt like a reasonable MVP shortcut, but the migration to WebSockets under production load was more disruptive than building it correctly the first time.
- Budget for map-matching earlier. We treated it as a “nice to have” polish item, but it directly affects perceived reliability, which affects retention.
- Load test with realistic geographic clustering, not evenly distributed synthetic data. Real demand clusters hard around business districts and transit hubs at specific hours, and that clustering is exactly what breaks naive geo-indexing approaches.
None of these are complicated fixes in isolation. The expensive part is realizing you need them before a demand spike forces the issue in production.




