BicaDriver — Real-Time Ride-Hailing
BicaDriver—Real-TimeRide-Hailing
A real-time ride-hailing platform where the hard problems all live in the same place: money moving correctly while a phone loses signal in moving traffic.
The Problem
BicaDriver connects car owners who need reliable transport with professional drivers who want consistent work. It has to be real-time end to end — drivers receive ride requests, owners watch their assigned driver move on a live map, payments settle and split automatically, and admins see the whole platform's state. The catch: it all runs on phones in moving cars on Nigerian mobile networks, so it has to stay correct even as connections drop mid-trip.
Architecture Thinking
A ride-hailing app looks like a maps problem and turns out to be a state problem. At any moment a trip sits in one of thirteen states, two phones — owner and driver — need to agree on which one, a payment is settling in the background, and the network is unreliable by definition because people open the app in moving cars. I built BicaDriver around that reality: durable truth in PostgreSQL, fast-moving ephemeral state in Redis, and a real-time layer that treats disconnection as the normal case rather than the exception. A single React 19 + TypeScript codebase ships to Android and iOS through Capacitor; the backend is NestJS on Fastify.
Real-Time Layer
Live tracking and dispatch run over a token-authenticated Socket.io /rides namespace (rides.gateway.ts) with role-scoped rooms — user:{id}, drivers, tracking:driver:{id} — so a location broadcast reaches exactly the people who should see it. Driver GPS is throttled to one update per second before it touches Redis, and trip points accumulate in a Redis list for final distance settlement instead of writing to Postgres on every tick.
Idempotency & Payment Integration
Payments are the part that can't be wrong. Every mutating request carries an X-Idempotency-Key generated client-side in api.service.ts, so a retry after a dropped response settles once instead of double-charging. Money runs through Monnify with a per-driver sub-account model, so platform commission and driver earnings split at settlement; webhooks are signature-verified, and the provider's many success statuses (PAID, OVERPAID, SUCCESSFUL) collapse into one internal PaymentStatus enum.
The key is generated client-side, once per mutation, with a fallback for older webviews:
interface RequestOptions {
idempotencyKey?: string; // safe-retry token, one per mutation
}
const generateUUID = () =>
crypto.randomUUID?.() ?? fallbackUuidV4();
Resilience & Offline
The client assumes the socket will flap. While a trip is ASSIGNED or IN_PROGRESS, the auth layer suppresses the spurious 401s reconnection would otherwise trigger (App.tsx), so a signal drop never logs a driver out mid-ride. On launch the app calls /rides/current to recover an active trip or a pending payment exactly where it left off, and GPS accuracy is tiered — a fresh high-accuracy fix for pickup, a few seconds of staleness tolerated for tracking (CapacitorService.ts).
And concurrent 401s don't trigger a stampede of refresh calls — they share a single in-flight refresh:
let refreshPromise: Promise<string> | null = null;
async function attemptTokenRefresh(): Promise<string> {
if (refreshPromise) return refreshPromise; // already refreshing -> reuse it
refreshPromise = (async () => {
const token = await requestNewToken(); // hits /auth/refresh once
saveToken(token);
return token;
})().finally(() => { refreshPromise = null; });
return refreshPromise;
}
Pricing & Driver Quality
Fares come from one server-side engine (rides.service.ts) with three distance/traffic zones, and the pricing parameters are snapshotted onto the trip at request time so an invoice never drifts when settings change. A rating system auto-suspends drivers who fall below quality thresholds, with every score change written to an audit log.
Constraints
This is Nigeria-first infrastructure: mobile networks drop constantly, so offline resilience is a requirement, not a polish item. The payments are real money owed to real drivers, so "probably charged once" isn't acceptable — idempotency keys and verified webhooks are mandatory. And it's a two-sided real-time market running on phones, so battery and bandwidth are budgets I spend deliberately — GPS throttling, accuracy tiering, notifications offloaded to a queue — rather than ignore.
What It Demonstrates
- Idempotent financial mutations — an
X-Idempotency-Keyon every write makes retries safe, eliminating double-charges when the network drops between request and response. - State-machine modeling — thirteen explicit trip states keep two clients and the server in agreement instead of inferring intent from side effects.
- Real-time at scale — a namespaced Socket.io layer with role-scoped rooms and 1/sec GPS throttling delivers live tracking without overwhelming Redis.
- Failure-first design — 401 suppression during active trips and
/rides/currentrecovery treat disconnection as the expected case, not an error state. - Background-work isolation — Firebase push and Resend email run on BullMQ queues, so a slow third party never blocks an API response.
- Payment-provider integration — Monnify sub-accounts with signature-verified webhooks and status normalization turn a messy external API into one clean internal contract.
- Concurrency-safe auth — concurrent 401s share a single in-flight token refresh, so a reconnection storm can't trigger a refresh stampede.
Outcome
The system handles three roles, thirteen trip states, live tracking, split payments, and KYC onboarding, with Sentry error tracking, tiered rate limiting, and a BullMQ job dashboard. It's the project where I learned that real-time payments are mostly about getting the state machine right under failure.