DEV Community

Mittal Technologies
Mittal Technologies

Posted on

How We Built a Real-Time Ride-Sharing App with Node.js, Socket.io, and Google Maps API


Building a ride-sharing app sounds like one of those "just follow a tutorial" projects until you're actually in it. Then you realize pretty quickly that the tutorial version and the production version are completely different animals.
At Mittal Technologies, we recently shipped a full ride-sharing platform, driver matching, real-time routing, live location tracking, fare calculation, payment flows, the whole thing. This post is a walkthrough of the architecture decisions we made, the problems we didn't anticipate, and the things we'd do differently. No fluff, just the real build.

The Core Challenge: Everything Happens at Once

The fundamental difficulty with ride-sharing (versus, say, a food delivery app) is the density of concurrent real-time events. When a rider requests a trip, you need to:

  1. Find nearby available drivers
  2. Broadcast the request to eligible drivers
  3. Handle the first acceptance and cancel broadcasts to others
  4. Begin live location tracking for both rider and driver
  5. Update ETAs as conditions change
  6. Handle dropoff and trigger payment

All of this needs to happen with sub-second responsiveness. A user waiting 4 seconds for a match confirmation isn't "slow", it feels broken.

Stack Overview

Backend: Node.js + Express
Real-time: Socket.io
Mapping: Google Maps Platform (Directions, Distance Matrix, Geocoding)
Database: MongoDB (user/trip data) + Redis (live location state)
Auth: JWT + refresh token rotation
Payments: Stripe Connect (for driver payouts)
Hosting: AWS EC2 + ElastiCache for Redis

We chose Node.js for the obvious reason, event-driven, non-blocking I/O is a natural fit for a system where you're constantly waiting on external events (driver acceptance, location pings, payment confirmations) without wanting to block threads.

Real-Time Architecture: Socket.io Room Strategy

The part most people under-architect. The naive approach is broadcasting everything to everyone and filtering on the client. That works fine at 10 concurrent users. At 1,000, you've built yourself a very expensive problem.

Our approach used Socket.io rooms aggressively.

Driver rooms by geohash zone:
When a driver comes online, they join a room corresponding to their geohash zone (we used precision-5 geohash, roughly 5km x 5km cells). When a ride is requested, we compute the rider's geohash and broadcast only to that room, plus adjacent cells to handle edge cases.

// Driver comes online
socket.on('driver:online', async (data) => {
const { driverId, lat, lng } = data;
const geohash = Geohash.encode(lat, lng, 5);

// Join the zone room
socket.join(zone:${geohash});

// Also join adjacent zones for edge-of-cell coverage
const neighbors = Geohash.neighbors(geohash);
neighbors.forEach(neighbor => socket.join(zone:${neighbor}));

// Store live state in Redis (TTL 30s, refreshed by heartbeat)
await redis.setex(driver:location:${driverId}, 30, JSON.stringify({
lat, lng, geohash, socketId: socket.id, available: true
}));
});

Trip rooms for matched rides:
Once a match is confirmed, both driver and rider join a trip-specific room. All subsequent location updates, status changes, and messages go through that room only.

// On match confirmation
const tripRoom = trip:${tripId};
driverSocket.join(tripRoom);
riderSocket.join(tripRoom);

// Location updates now scoped to this room only
socket.on('location:update', async ({ lat, lng }) => {
const tripId = await getActiveTripForSocket(socket.id);
if (!tripId) return;

io.to(trip:${tripId}).emit('location:update', {
role: socket.data.role, // 'driver' or 'rider'
lat,
lng,
timestamp: Date.now()
});

// Refresh Redis state
await redis.setex(socket:location:${socket.id}, 60, JSON.stringify({ lat, lng }));
});

The Matching Algorithm

We went through three iterations here. The first was too naive (just nearest driver). The second was too complex (we briefly went down a machine learning rabbit hole we had no business going down for v1). The third is what we shipped.

The production matching logic scores available drivers on three factors:

async function scoreDriversForRequest(riderLat, riderLng, drivers) {
const scores = await Promise.all(drivers.map(async (driver) => {
// Factor 1: Distance (Google Distance Matrix for real road distance, not straight line)
const distanceData = await getDistanceMatrix(
{ lat: driver.lat, lng: driver.lng },
{ lat: riderLat, lng: riderLng }
);
const etaMinutes = distanceData.duration.value / 60;
const distanceKm = distanceData.distance.value / 1000;

// Factor 2: Driver acceptance rate (stored per driver, updated async)
const acceptanceRate = await redis.get(`driver:acceptance:${driver.id}`) || 0.8;

// Factor 3: Trip completion quality score
const qualityScore = driver.rating / 5;

// Weighted scoring
const score = (
  (1 / (etaMinutes + 1)) * 0.5 +   // 50% weight on proximity
  parseFloat(acceptanceRate) * 0.3 +  // 30% weight on reliability
  qualityScore * 0.2                  // 20% weight on quality
);

return { driver, score, etaMinutes, distanceKm };
Enter fullscreen mode Exit fullscreen mode

}));

return scores.sort((a, b) => b.score - a.score);
}

One non-obvious problem: Google Distance Matrix API charges per element (origin × destination pair). With 20 nearby drivers and 1 rider, that's 20 API calls per request event. At scale that gets expensive fast. We added a Redis cache on recently computed routes (5-minute TTL) that reduced our API usage by about 60% in practice.

The Race Condition Problem

This one bit us in testing and took a day to properly solve.

When a ride request broadcasts to a driver zone, multiple drivers can accept nearly simultaneously. Without proper locking, two drivers could both receive confirmation. Our first attempt at fixing this was a database-level unique constraint on tripId + status = 'accepted'. That works, but the second driver still gets a confusing error with no good recovery path.

The proper fix was a Redis distributed lock on the trip acceptance:

async function acceptTrip(tripId, driverId, socket) {
const lockKey = lock:trip:${tripId};
const lockValue = ${driverId}:${Date.now()};

// Attempt to acquire lock (10s expiry as safety net)
const acquired = await redis.set(lockKey, lockValue, 'NX', 'EX', 10);

if (!acquired) {
// Another driver got there first
socket.emit('trip:already_accepted', { tripId });
return;
}

try {
// Check trip is still unmatched
const trip = await Trip.findById(tripId);
if (trip.status !== 'pending') {
socket.emit('trip:already_accepted', { tripId });
return;
}

// Commit the match
trip.driverId = driverId;
trip.status = 'matched';
trip.matchedAt = new Date();
await trip.save();

// Notify rider, cancel broadcast to other drivers
io.to(`trip:rider:${trip.riderId}`).emit('trip:matched', {
  driverId,
  eta: /* computed ETA */
});
io.to(`zone:${trip.requestZone}`).emit('trip:cancelled', { tripId });
Enter fullscreen mode Exit fullscreen mode

} finally {
// Always release the lock
const currentValue = await redis.get(lockKey);
if (currentValue === lockValue) {
await redis.del(lockKey);
}
}
}

Google Maps Integration: The Parts That Surprised Us

Directions API for route display — straightforward. You get a polyline, you decode it, you render it.
Real-time ETA updates — less straightforward. Hitting the Directions API on every location ping is expensive and unnecessary. We poll it every 90 seconds and on significant route deviations (>200m from expected path), using driver location from Redis rather than waiting for a socket event.
Geocoding for pickup/dropoff — the forward geocoding (address → coordinates) from the search input was fine. The reverse geocoding (coordinates → readable address for pickup confirmation) needs caching. We saw the same coordinates geocoded dozens of times within minutes. Simple Redis cache keyed on truncated lat/lng (4 decimal places) with 24-hour TTL solved that.

What We'd Do Differently

WebSockets vs Socket.io: Socket.io's auto-reconnection and room management are genuinely useful, but we carry its full weight even for connections that never need the fallback transports. For v2, we'd evaluate raw WebSocket with a custom reconnection layer for driver clients (which are known environments) and keep Socket.io only for rider-facing web clients.
Location update frequency: We defaulted to 3-second driver location pings. For active trips, 3 seconds is fine. For nearby-but-unmatched drivers, 10 seconds is more than sufficient and meaningfully reduces Redis write load.
Testing real-time flows: Unit tests don't really capture Socket.io behavior at scale. We should have built load testing with Artillery or k6 earlier. We found a socket room leak in staging that we should have caught in testing.

Performance in Production

Initial load after launch: ~400 concurrent WebSocket connections during peak. Redis handling ~2,000 ops/second. Average match time: 4.2 seconds from request to driver confirmation. Google Maps API costs: higher than projected in month 1 before the caching improvements, then normalized to budget by month 2.

The architecture held. The main scaling bottleneck we're watching is the matching algorithm's Distance Matrix calls as ride volume grows, we're evaluating switching to a cached-graph approach for high-density urban zones as a next step.

If you're building something similar and want to talk through architecture decisions, feel free to drop questions in the comments. And if you're looking for a team that's shipped this kind of real-time infrastructure, Mittal Technologies has the case study to prove it.

Top comments (0)