Real-Time Ride Matching: Lessons from Building Routto

April 22, 2026 (2mo ago)

Routto is a female-only ride-hailing platform I architected and built end to end: React Native apps for rider and driver, a NestJS microservice-style backend, a React admin dashboard, and a marketing site. The product requirement that shaped almost every technical decision was trust — riders and drivers both needed strong identity verification before they could use the platform at all, and every ride needed to feel monitored in a way a generic ride-hailing app doesn't have to worry about.

Two problems ended up dominating the build: real-time ride matching over WebSockets, and a verification pipeline that combined AI inference with manual review.

Ride matching over WebSockets, not polling

Early on it was tempting to just poll a "nearby drivers" endpoint every few seconds — it's simpler to reason about and easier to test. It falls apart fast once you care about matching latency and battery life on the driver's phone, which is running the app in the background for an entire shift. We moved to Socket.IO for both location updates and match offers:

@SubscribeMessage("driver:location")
handleLocationUpdate(client: Socket, payload: { lat: number; lng: number }) {
  const driverId = this.getDriverId(client);
  this.locationStore.update(driverId, payload);
 
  // Only re-run matching for riders actively waiting in this driver's
  // geofence cell — recomputing the whole city on every ping doesn't scale.
  const affectedRiders = this.geo.ridersWaitingNear(payload);
  affectedRiders.forEach((rider) => this.tryMatch(rider));
}

The geofencing detail in that comment mattered more than it looks. Naively recomputing matches against every waiting rider on every location update works fine in a demo and falls over the moment you have more than a handful of drivers moving at once. Bucketing drivers and riders into geohash cells and only re-running the match logic for riders in affected cells kept the matching loop cheap enough to run on every location tick instead of on a timer, which is what actually got match latency down to something that felt instant.

Verification: AI inference is the first pass, not the last

Gender and identity verification couldn't be a pure ML classifier making a silent yes/no decision — false rejects lock out legitimate users, and false accepts undermine the entire premise of the platform. We landed on a two-stage pipeline: an automated inference step that scores a submission and routes it, followed by manual review for anything that isn't a confident accept.

async function processVerification(submissionId: string) {
  const result = await inferenceService.score(submissionId);
 
  if (result.confidence >= AUTO_APPROVE_THRESHOLD) {
    return approve(submissionId, { reviewedBy: "system" });
  }
 
  // Confident rejections still go to a human — an automated reject
  // with no appeal path is how you lose users who did nothing wrong.
  return queueForManualReview(submissionId, result);
}

The threshold tuning was an ongoing conversation with the product side, not a one-time constant — we adjusted AUTO_APPROVE_THRESHOLD more than once after watching how the manual review queue actually behaved in the first weeks of real traffic.

Incident management as a first-class feature, not an afterthought

Because trust was the whole point of the product, in-ride incident reporting and admin escalation couldn't be bolted on later. Every active ride carried a lightweight incident channel from the start, so if we'd tried to retrofit it after the fact we'd have been rewriting the ride lifecycle instead of adding a feature to it. That's the main structural lesson from this project: when trust and safety are the product, they need to be part of the data model on day one, not a flag added to an existing Ride entity six months in.