Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 048 / 100

Case study: live ride tracking — lessons from production

How we built real-time ride tracking with Kafka, Redis and Socket.IO.

Expona Admin8 min read

Case study: live ride tracking

Building real-time ride tracking is one of the most demanding use cases you'll encounter as a backend engineer. You're balancing millions of GPS updates per second, dozens of concurrent connections per ride, network volatility, and the absolute requirement that a rider never sees a stale location on their map. In this article, I'll walk you through how we built a live ride tracking system at scale using Kafka, Redis, and Socket.IO—and the architectural decisions that kept latency under 500ms while keeping infrastructure costs reasonable.

The pipeline: end-to-end flow

Before diving into implementation, let me outline the complete journey of a single GPS update from the driver's phone to the rider's screen.

The driver app captures GPS location every few seconds (often 1–2 Hz depending on road conditions). That update hits our location ingestion API endpoint. The API publishes the raw GPS event to a Kafka topic keyed by ride ID. A stream processor subscribes to that topic, transforms the data if needed, and writes the latest location to Redis under a ride-specific key. Finally, a WebSocket gateway subscribes to ride updates from Redis (or receives a notification channel), and pushes the new position to all connected riders in real time via Socket.IO.

This pipeline ensures:

  • No single component becomes a bottleneck.
  • GPS updates are durably persisted in Kafka for audit and replay.
  • Redis acts as the hot cache for immediate client reconnections.
  • WebSocket guarantees low-latency push delivery.

Here's a minimal flow diagram as a mental model:

typescript

// Driver app → API → Kafka → Processor → Redis → WebSocket Gateway → Rider browser
Driver GPS (1–2 Hz)
    ↓
POST /rides/:rideId/location
    ↓
publish to kafka-topic: "ride-locations" (key: rideId)
    ↓
KafkaConsumer reads, batches 10–20 events
    ↓
Redis SET ride:location:{rideId} {lat, lng, timestamp}
    ↓
Redis PUBLISH channel:ride:{rideId} event
    ↓
Socket.IO Gateway receives, broadcasts to room ride:{rideId}
    ↓
Rider browser updates map marker in <100ms

Key by ride: maintaining order

Kafka's partitioning model is your best friend here. Every GPS update for a single ride must be processed in order—you cannot let update #5 arrive before update #3 on the rider's screen. The solution is dead simple: use the ride ID as the Kafka message key.

When you publish with a key, Kafka guarantees that all messages with the same key go to the same partition, and a consumer reading that partition will see them in order. No coordination overhead, no distributed locks, no race conditions.

typescript

import { Kafka } from 'kafkajs';

const kafka = new Kafka({
  clientId: 'location-api',
  brokers: ['localhost:9092'],
});

const producer = kafka.producer();

async function publishLocationUpdate(
  rideId: string,
  lat: number,
  lng: number,
  timestamp: number,
) {
  await producer.send({
    topic: 'ride-locations',
    messages: [
      {
        key: rideId, // All updates for this ride go to same partition
        value: JSON.stringify({
          rideId,
          lat,
          lng,
          timestamp,
        }),
      },
    ],
  });
}

In production, we set the partition count to around 64 and let Kafka round-robin rides across partitions. This gives us parallelism while respecting per-ride ordering. If you have 100k concurrent rides and 64 partitions, each partition handles ~1,500 rides, and each partition's consumer processes updates sequentially.

One caveat: if a ride spans multiple delivery regions or you want to shard by geography, you might use a composite key like `region:rideId`. Always think about your consumer topology—if you have 8 consumers in a group and 64 partitions, each consumer owns 8 partitions and thus 8×1,500 = 12k rides.

Redis for latest state: reconnection resilience

WebSocket connections drop. Network hiccups, browser tabs sleep, mobile devices switch WiFi—it happens constantly. When a rider's connection restores, they shouldn't see their map frozen at a three-minute-old position. That's where Redis comes in.

After publishing to Kafka, the stream processor writes the latest location to Redis as a simple key-value pair. The TTL is set to slightly longer than the ride duration (we use 2 hours) to handle edge cases.

typescript

import { createClient } from 'redis';

const redis = createClient({
  host: 'localhost',
  port: 6379,
});

async function updateLatestLocation(
  rideId: string,
  location: { lat: number; lng: number; timestamp: number },
) {
  const key = `ride:location:${rideId}`;
  // Store with 2-hour TTL
  await redis.setEx(key, 7200, JSON.stringify(location));
}

// When a client connects or reconnects
async function getLatestLocation(rideId: string) {
  const key = `ride:location:${rideId}`;
  const data = await redis.get(key);
  if (data) {
    return JSON.parse(data);
  }
  return null;
}

In your WebSocket gateway, on `socket.on('connect')`, fetch the latest location from Redis and send it immediately:

typescript

import { Server } from 'socket.io';

const io = new Server(httpServer);

io.on('connection', async (socket) => {
  const rideId = socket.handshake.query.rideId as string;

  // Send latest location immediately
  const latest = await getLatestLocation(rideId);
  if (latest) {
    socket.emit('location', latest);
  }

  // Join the ride room for live updates
  socket.join(`ride:${rideId}`);
});

This pattern eliminates the perception of "blank map" on reconnection and saves you from having to replay Kafka messages for every connection drop.

Throttle at the edge: reducing noise

A modern smartphone's GPS fires at 1–5 Hz. A driver traveling at 60 km/h moves roughly 16 meters per second. Publishing every GPS ping means 5 messages per second per driver, and with 50k concurrent drivers, you're looking at 250k Kafka messages per second. That's not necessarily unsustainable, but it's overkill—riders don't need sub-second position updates.

We implemented throttling on two fronts:

First, on the driver app: only send a location update if the driver has moved more than 10 meters since the last update, or 5 seconds have passed, whichever comes first. This is basic spatial + temporal throttling.

Second, on the gateway: batch updates and send to clients at a fixed rate (e.g., 2 Hz). This smooths out burst traffic and ensures consistent UI refresh rates.

typescript

// Driver app throttling (pseudocode)
let lastLocation = null;
let lastUpdateTime = 0;
const DISTANCE_THRESHOLD = 10; // meters
const TIME_THRESHOLD = 5000; // 5 seconds

function onGPSUpdate(lat: number, lng: number) {
  const now = Date.now();
  
  if (!lastLocation) {
    sendUpdate(lat, lng, now);
    lastLocation = { lat, lng };
    lastUpdateTime = now;
    return;
  }

  const distance = haversine(lastLocation.lat, lastLocation.lng, lat, lng);
  const timeSinceLastUpdate = now - lastUpdateTime;

  if (distance >= DISTANCE_THRESHOLD || timeSinceLastUpdate >= TIME_THRESHOLD) {
    sendUpdate(lat, lng, now);
    lastLocation = { lat, lng };
    lastUpdateTime = now;
  }
}
typescript

// Gateway batching: collect updates, broadcast once per 500ms
class LocationBroadcaster {
  private pending = new Map<string, object>(); // rideId → latest location
  private io: Server;

  constructor(io: Server) {
    this.io = io;
    setInterval(() => this.flush(), 500);
  }

  queue(rideId: string, location: object) {
    this.pending.set(rideId, location);
  }

  private flush() {
    for (const [rideId, location] of this.pending.entries()) {
      this.io.to(`ride:${rideId}`).emit('location', location);
    }
    this.pending.clear();
  }
}

With these throttles in place, you'll see a 4–8x reduction in message throughput. Riders still perceive smooth movement because their browser interpolates between points, and latency remains under 1 second end-to-end.

Common mistakes

Publishing every GPS ping without throttling: You'll blow through your Kafka broker CPU and Redis bandwidth before you reach 10k concurrent rides. Throttle early.

Not using the ride ID as the Kafka key: You lose ordering guarantees. If updates 3 and 5 arrive out of order, the rider's map jumps backward, breaking trust in the system.

Forgetting Redis TTL: After a week, you'll have gigabytes of stale ride data sitting in Redis. Always set a TTL proportional to your ride duration plus a safety margin.

Pushing every update to every client: If a ride room has 1 driver + 5 riders + 2 support staff monitoring, that's 8 connections receiving redundant updates. Use Socket.IO rooms efficiently, and let the client deduplicate if needed.

No dead-letter queue for Kafka: When a location is malformed or the processor crashes, you need somewhere to route those messages. Add a DLQ topic and an alert.

Key takeaways

  • Use Kafka's key-based partitioning to guarantee per-ride ordering without additional coordination.
  • Cache the latest location in Redis so reconnecting clients get instant state, not a stale map.
  • Throttle GPS updates at the driver app and gateway to reduce message volume by 5–10x while maintaining smooth UX.
  • Pub/Sub (Redis channels or Socket.IO rooms) bridges Kafka's durability with WebSocket's low latency.
  • Monitor end-to-end latency; aim for sub-500ms from driver GPS to rider screen in normal conditions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles