/ Docs / Fastest-Route Finding v1 · 2026
Geo · Routing

Fastest-Route Finding

How RideChain answers two questions cheaply — the fastest single A→B route for ETA & navigation, and the optimal multi-stop route (VRP) that bundles parcels into one trip and drives cost-per-parcel down.

1. Overview — two routing questions

Routing inside RideChain is not one problem, it is two, and conflating them is the most common mistake. The first is single-route: given one origin and one destination, what is the fastest road path, and what is the ETA? This drives navigation, partner offers, and the live arrival countdown the booker watches. The second is multi-stop: given a vehicle and a pile of parcels with different pickups and drops, what is the cheapest sequence of stops that respects capacity and time windows? That is the Vehicle Routing Problem (VRP), and it is the literal cost engine of the platform.

🧭

Fastest single route

One A→B path optimised for travel time. Feeds the partner's turn-by-turn navigation, the booking ETA, and the matching engine's "how far is this partner?" question (see Nearest-Partner Matching).

🚚

Optimal multi-stop route (VRP)

Many parcels, one trip. Solving the VRP well is what lets a milk-run carry 20 parcels for the price of one dedicated run — the difference between a ₹70–90 urgent fare and a ₹25–45 bundled one.

Both questions are answered against a road graph we host ourselves. RideChain runs a self-hosted OSRM (Open Source Routing Machine) instance on top of OpenStreetMap (OSM) rural extracts for the districts we operate in. Because the routing engine is ours, a route query costs us essentially compute only — not a per-call API fee. That ~₹0 marginal cost is what makes it economical to call routing thousands of times a day for ETAs, re-routes and VRP solves; see Scale & Low-Cost for how this folds into unit economics.

2
Routing questions (single + VRP)
~₹0
Marginal cost per OSRM query
cost ÷ N
Per-parcel cost on a bundled run
₹25–45
Bundled milk-run, 15 km interior
Mental model: the single-route engine sells speed (good ETAs, good navigation); the VRP engine sells cheapness (more parcels per trip). The data store underneath is shared: PostGIS holds geometry (Points, hubs, partner positions, service areas) and Redis caches hot routes and ETAs so we rarely re-solve the same path twice.

2. Routing engine architecture

The Go modular monolith exposes a geo module that is the single entry point for every routing question. Callers (matching, booking ETA, the VRP solver, the Dispatch app) never talk to OSRM directly — they call the geo module, which owns caching, fallback, map-matching and the rural weighting layer. This keeps routing policy in one place and lets us swap the backend without touching callers.

  1. Caller asks the geo moduleroute(origin, dest, vehicleClass) or vrp(stops, vehicle, constraints).
  2. Map-match the inputs — snap raw GPS coordinates to the nearest road segment so a point in a field routes from the road, not the crop.
  3. Check Redis — for a corridor we have computed recently, return the cached route/ETA immediately.
  4. Call self-hosted OSRM — on a cache miss, query our OSRM on the regional OSM extract; OSRM returns geometry + base travel time.
  5. Apply rural weighting — adjust the raw OSRM time for surface, vehicle class, season and safety (see §4).
  6. Fallback if OSRM cannot answer — if our extract has no road to a remote hamlet (or OSRM is down), fall back to a maps provider, then to straight-line + landmark anchoring.
  7. Cache & return — write the result to Redis with a TTL and return route geometry + adjusted ETA to the caller.
flowchart TB
  C["Caller: matching / ETA / VRP / Dispatch"] --> GEO["Go geo module
(routing policy owner)"] GEO --> MM["Map-matching
snap GPS to nearest road"] MM --> CACHE{"Redis route/ETA
cache hit?"} CACHE -- "hit" --> RET["Return cached route + ETA"] CACHE -- "miss" --> OSRM["Self-hosted OSRM
(rural OSM extract)"] OSRM -- "ok" --> WGT["Apply rural weighting
(surface, vehicle, season)"] OSRM -- "no road / down" --> FB["Fallback chain"] FB --> PROV["Maps provider"] FB --> SL["Straight-line + landmark
+ Point anchor"] PROV --> WGT SL --> WGT WGT --> STORE["Write Redis (TTL)"] STORE --> RET classDef core fill:#e8f0fb,stroke:#1f5fae,color:#143d6e; classDef warn fill:#fff3e0,stroke:#f4920b,color:#8a5200; class GEO,OSRM core; class FB,PROV,SL warn;
A route request. The geo module owns the pipeline: map-match → Redis → self-hosted OSRM → rural weighting → cache. The provider and straight-line legs are fallbacks only, so steady-state routing cost stays near zero.

Map-matching deserves emphasis. Rural GPS is noisy: a partner's phone may report a fix 30–80 m off the actual road, or inside a field. Before routing, the geo module snaps the coordinate to the most plausible road segment given the recent track (OSRM's /match service does exactly this). Without snapping, a route would start "in the middle of a paddy field" and produce nonsense distances. Snapping is also what keeps the live partner trail on the road as it moves (see Geolocation).

Why self-hosting wins here: a commercial routing API charges per call, so an ETA-refresh-every-15-seconds product would be ruinously expensive. Owning OSRM converts that to fixed compute we already pay for on Cloudflare-fronted infra, making aggressive caching and frequent re-routing affordable.

3. Fastest single route & ETA

For a single A→B path, OSRM precomputes the road graph using Contraction Hierarchies (CH) — a preprocessing technique that makes shortest-path queries (a Dijkstra-style search over a contracted graph) return in microseconds even on a large district extract. The raw output is a route geometry plus a base travel time. We never ship that base time to the booker untouched.

The displayed ETA is the base time multiplied by a rural reality factor:

ETA = OSRM_base_time × surface_factor × vehicle_factor × season_factor × safety_factor — each factor ≥ 1.0, so the rural-adjusted ETA is always conservative relative to a naive map estimate.

The factors come from the road-graph weighting in §4. A generic maps ETA assumes a tarred highway at car speed; on an unpaved monsoon-soft village road, a loaded mini-truck moves at a fraction of that, so applying the factor stops us from promising arrivals we cannot keep. A slightly pessimistic ETA that we beat builds trust; an optimistic one we miss destroys it.

flowchart LR
  A["Snapped origin + dest"] --> CH["OSRM Contraction Hierarchies
(Dijkstra-style query)"] CH --> BASE["Route geometry + base time"] BASE --> F1["× surface factor"] F1 --> F2["× vehicle factor"] F2 --> F3["× season / monsoon factor"] F3 --> F4["× daylight / safety factor"] F4 --> ETA["Conservative ETA shown to booker"] ETA --> LIVE["Recompute as partner moves
(live ETA)"]
From raw OSRM time to a trustworthy ETA. CH gives a fast base time; the rural factors push it to a realistic, conservative number; live position drives continuous recompute.

Live ETA updates: the partner's position streams in (see Geolocation). As each fix arrives, the geo module re-snaps it and recomputes the remaining route from the partner's current point to the next stop, so the countdown the booker sees is always "from where the partner actually is now." We debounce this — recompute on a meaningful move or every N seconds, not on every jittery fix — and serve the recompute from cache where the corridor is unchanged.

ETA is an estimate, not a promise of a fixed clock. Re-snapping plus the rural factor means the number shifts as conditions reveal themselves (a road block, a slow ferry). The UI always shows a small buffer band rather than a single false-precision minute.

4. Rural road-graph weighting

Off-the-shelf maps are built for cars on highways in cities. They will happily route a mini-truck down a dirt track that floods in July, or send a tractor along a route that a bike could take but a tractor cannot. RideChain corrects this by treating every road edge as carrying a set of weight factors, applied on top of OSRM's base time. The weighting is also vehicle-class aware: the same physical road has a different cost for a cycle, a bike, an auto, a Tata Ace, or a tractor-trolley.

Edge factorWhat it capturesEffect on weight
SurfacePaved (tar/concrete) vs unpaved (gravel/murram) vs dirt/kachcha track.Paved ≈ 1.0 · unpaved ≈ 1.4–1.8 · dirt ≈ 2.0+ (slower, sometimes impassable).
Vehicle restrictionWidth / weight / clearance limits — a tractor route ≠ a bike route ≠ a mini-truck route. A narrow bund a bike crosses is a no-go for a Bolero.Per vehicle class: edge marked impassable (∞) for over-class vehicles, normal for fitting ones.
Seasonal / monsoonRoads that wash out, flood, or turn to slush in the rains; date-aware so the same edge is cheap in summer, costly in monsoon.Dry ≈ 1.0 · monsoon ≈ 1.5–3.0, or impassable when flagged flooded.
Daylight / safetyStretches risky after dark (no lights, isolated, wildlife, poor mobile coverage); time-of-day aware.Day ≈ 1.0 · night ≈ 1.2–1.6, or routed around for high-value / lone-partner trips.
Ferry / river crossingEdges that depend on a boat/ferry or a seasonal causeway, with operating hours and capacity.Adds wait + crossing time; impassable outside operating hours or when the crossing is closed.

Why generic maps fail rural India: they lack the surface metadata, treat every vehicle as a car, ignore monsoon seasonality, and have no concept of a ferry timetable on a village river crossing. The result is confidently-wrong routes and ETAs. RideChain's weighting layer turns OSRM's geometric answer into an operational one — the route a real Bolero can actually drive today, in this weather, at this hour. The factors are sourced from OSM tags where present, enriched by partner-reported road conditions and manual overrides, and stored alongside the graph in PostGIS.

The same factor table feeds both the single-route ETA (§3) and the VRP edge costs (§5), so a bundled run never plans a leg down a road the chosen vehicle cannot take. Vehicle classes match the fleet catalogue in System Architecture (cycle, bike, e-rick, auto, Bolero, Tata Ace, tractor-trolley, mini-truck, tempo).

5. The VRP — multi-stop bundling

This is the cost lever. The Vehicle Routing Problem asks: given a vehicle with a fixed capacity, a set of parcels each with a pickup and a drop and possibly a time window, what stop sequence minimises total trip cost while serving every parcel? Solving it well is what lets one trip carry many parcels.

The economics are simple and brutal: cost-per-parcel = trip cost ÷ N, where N is the number of parcels on the trip. A dedicated single-parcel run has N=1, so the parcel eats the whole trip cost. A milk-run with N=20 splits the same fuel-and-time cost twenty ways. That is exactly why a bundled POINT → POINT parcel can be ₹25–45 while the same distance on an urgent dedicated run is ₹70–90 (see Commission & Pricing).

flowchart TB
  IN["Pending parcels
(pickups + drops, time windows)"] --> CLU["Cluster stops
by geography / corridor"] CLU --> SEQ["Sequence stops within a cluster
(savings / nearest-neighbour + 2-opt)"] SEQ --> CAP{"Within vehicle
capacity?"} CAP -- "no" --> SPLIT["Split batch or upgrade vehicle"] SPLIT --> CLU CAP -- "yes" --> TW{"Time windows
satisfiable?"} TW -- "no" --> SPLIT TW -- "yes" --> ASSIGN["Assign route to partner
+ compute cost-per-parcel"] ASSIGN --> DISP["Dispatch run"] classDef core fill:#e8f0fb,stroke:#1f5fae,color:#143d6e; classDef warn fill:#fff3e0,stroke:#f4920b,color:#8a5200; class CLU,SEQ,ASSIGN core; class SPLIT warn;
The VRP solve loop: cluster stops → sequence within a cluster → check capacity → check time windows → assign to a partner and divide trip cost by N. Infeasible batches loop back to split or upgrade the vehicle.

Heuristics, not brute force. The VRP is NP-hard, so we never search for a provably-optimal tour. We use well-known heuristics that get within a few percent of optimal in milliseconds:

🪢

Savings (Clarke-Wright)

Start with one out-and-back route per parcel, then greedily merge the two routes whose merge "saves" the most distance, while capacity and time windows still hold. Great for building bundles from scratch.

🧮

OR-Tools-style metaheuristic

A constraint solver (Google OR-Tools-style) with capacity + time-window dimensions and guided local search, time-boxed so it returns a good answer within a budget rather than the perfect one eventually.

📍

Nearest-neighbour + 2-opt

Build a quick tour by always hopping to the closest unvisited stop, then improve it with 2-opt swaps that uncross the route. Cheap, fast, and a solid fallback when the solver is time-boxed out.

The VRP and matching are partners, not duplicates. Nearest-Partner Matching makes the bundling decision (is this parcel a candidate for a milk-run at all?); the VRP here computes the actual stop sequence for the chosen vehicle. The resulting route is executed as a last-mile milk-run, and the per-parcel saving flows straight into Scale & Low-Cost.

6. Milk-run scheduling

Not every bundle is solved fresh. The cheapest, most predictable runs are scheduled milk-runs: a fixed loop from a Block Hub out to a string of village Points and back, on a timetable. Parcels accumulate at the hub between runs; when the scheduled departure arrives (or the hub fills past a threshold), the run is sequenced by the VRP over whatever is staged and dispatched.

The key efficiency trick is the backhaul: the outbound leg carries parcels to villages; the return leg, which would otherwise run empty, carries village produce back to the mandi (market). Filling the empty return leg roughly halves the dead-kilometre cost of the loop and adds a second revenue stream from the same fuel.

flowchart LR
  HUB["🏬 Block Hub
parcels accumulate"] -->|"outbound: drop parcels"| V1["Village Point 1"] V1 --> V2["Village Point 2"] V2 --> V3["Village Point 3"] V3 -->|"backhaul: load produce"| MANDI["🧺 Mandi / market"] MANDI -->|"return leg, now full"| HUB classDef hub fill:#fff3e0,stroke:#f4920b,color:#8a5200; classDef green fill:#e7f6ec,stroke:#1f9d57,color:#0f5c33; class HUB hub; class MANDI green;
A scheduled milk-run loop. Outbound legs drop parcels at village Points; the return leg backhauls produce to the mandi so the empty kilometres earn their keep. The loop closes back at the Block Hub.
Why scheduled beats on-demand for the interior: a fixed timetable lets parcels pool, so N is large and cost-per-parcel is tiny; it also gives villagers a predictable "the RideChain run comes at 9 and 4" rhythm. On-demand stays for urgent parcels that cannot wait for the next loop. See Last-Mile Delivery for how the hub consolidates.

7. Re-routing & dynamic events

A planned route rarely survives contact with reality. A road blocks, a vehicle breaks down, an urgent parcel is booked along a route already in flight, or a partner deviates from the plan. Re-routing handles each by re-solving incrementally from the partner's current position over the remaining stops — never throwing away progress already made.

Dynamic eventResponse
Road block / closure reportedMark the edge impassable for this run; recompute the single route around it and re-sequence remaining stops.
Vehicle breakdown mid-routeFreeze custody at current point; re-dispatch the remaining stops to another partner/vehicle (see Last-Mile).
New urgent parcel near the live routeCheap-insertion check: if it fits capacity + windows with little detour, insert it; else spin a separate run.
Partner deviates from planned pathRe-snap, recompute remaining route from actual position, refresh ETA; flag if deviation is persistent.
flowchart TB
  EV["Dynamic event
(block / breakdown / urgent insert / deviation)"] --> POS["Take partner's current snapped position"] POS --> REM["Take remaining stops only"] REM --> KIND{"Event type?"} KIND -- "road block" --> BLK["Mark edge impassable
re-route around"] KIND -- "breakdown" --> RED["Re-dispatch remaining stops
to new vehicle"] KIND -- "urgent insert" --> INS{"Fits capacity + windows
with small detour?"} KIND -- "deviation" --> SNP["Re-snap + recompute remaining"] INS -- "yes" --> ADD["Insert stop into live route"] INS -- "no" --> NEW["Spin separate run"] BLK --> SOLVE["Incremental re-solve over remaining stops"] RED --> SOLVE ADD --> SOLVE SNP --> SOLVE SOLVE --> PUSH["Push updated route + ETA to Dispatch / Partner app"]
Dynamic re-routing always starts from the partner's current position over the remaining stops, classifies the event, and runs an incremental re-solve rather than a full replan — keeping completed work intact.

8. Performance & caching

Routing is called constantly — every ETA refresh, every match candidate, every VRP solve — so it must be cheap per call. We keep it cheap by computing as little as possible at request time.

  1. Precompute hot corridors — the handful of hub→village and village→mandi paths used every day are computed once and pinned in cache; they almost never change.
  2. Batch ETA queries — OSRM's table/matrix service answers many origin→destination pairs in one call, so the VRP gets its whole distance matrix in a single round-trip instead of N² queries.
  3. Redis cache with TTL — route geometry and ETAs are stored with a time-to-live; a repeat query for the same corridor in the TTL window is a memory read, not an OSRM solve.
  4. Invalidate on event — a road block, flood flag or partner-reported closure invalidates affected cache keys immediately, so we never serve a stale route around a real obstacle.
  5. Shard by region — each district/region runs its own OSRM extract and cache shard, so load scales horizontally and a query only touches the graph it needs.
The compounding effect: self-hosted OSRM (≈₹0 per call) plus aggressive caching plus regional sharding means routing cost grows far slower than volume. That is a direct input to the per-delivery margin modelled in Scale & Low-Cost.

9. Edge cases & failure modes

Routing in the rural interior is the unhappy path by default — missing roads, stale maps, floods, broken-down trucks and noisy GPS are the norm, not the exception. Every risk below has a defined mitigation; the full catalogue lives in the Edge-Case Catalog.

ScenarioMitigation
OSRM has no road for a remote hamletFall back to straight-line distance + nearest landmark + a manually-placed Point anchor so the partner still gets a destination and a sane ETA buffer.
Stale OSM data (road exists in reality but not the map)Manual road overrides in PostGIS + partner-reported new roads; the override layer is applied on top of the OSM extract until the next refresh.
Monsoon-flooded routeSeasonal/monsoon weighting marks the edge impassable; the engine reroutes around it and recomputes ETA conservatively.
Vehicle physically cannot traverse a planned edgeVehicle-class graph marks the edge impassable for that class (a Bolero ≠ a bike), so the route is never planned down it in the first place.
GPS reports off-road / jitterMap-matching snaps the fix to the nearest plausible road segment using the recent track before any routing happens.
Route cache is stale after a real-world changeTTL bounds staleness; a road-block/flood/closure event invalidates affected keys immediately so the next query re-solves.
VRP is infeasible (capacity or time windows cannot fit)Split the batch into two runs or upgrade to a higher-capacity vehicle; never silently drop a parcel from a run.
Breakdown mid-routeFreeze custody at the current point and re-dispatch the remaining stops to a new partner/vehicle (see Last-Mile Delivery).
Ferry / river crossing closed (hours or weather)Crossing edge becomes impassable outside operating hours; reroute via an alternate crossing or hold the run for the next window.
ETA comes out wildly off actualRecompute from the live position and add a conservative buffer band; persistent miss feeds back into the weighting factors for that corridor.
Maps provider and OSRM both downServe last-known cached routes and degrade gracefully to straight-line + landmark guidance; Dispatch is alerted to assist manually.
Very large VRP (too many stops to solve in budget)Cap stops per run and time-box the solver; the heuristic returns the best route found within the budget rather than hanging on optimality.