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

Live location tracking architecture in Kafka & WebSockets

Showing a moving vehicle on a map in real time.

Expona Admin9 min read

When a rider opens your app and watches a driver approach in real time, they're seeing the result of a carefully orchestrated pipeline that handles thousands of location updates per second. In this article, I'll walk you through a live location tracking architecture that powers real-time vehicle visibility—the kind you see in ride-hailing, delivery, and logistics platforms.

Why live location tracking matters

A stationary blip on a map isn't useful. Users expect to see vehicles moving smoothly, arriving in seconds, not minutes. The difference between a refresh rate of 5 seconds and 30 seconds is the difference between trust and frustration. When you're waiting for a ride, every second matters.

But moving data in real time at scale introduces complexity. Mobile networks are unreliable. GPS is noisy. Users can spoof their location. And you can't afford to broadcast every driver's position to every user—that's a scaling nightmare. This architecture solves those problems with clear separation of concerns: ingest, validate, store, and fan-out.

Ingest — Collecting GPS updates from mobile apps

The journey starts with your mobile app (iOS, Android, or both). Every 3–5 seconds, your driver's app captures their current GPS coordinates and sends them somewhere. You have two main options: HTTP polling or persistent WebSocket.

HTTP approach: Simple but chattier. Each update is a separate request. Latency is higher because there's no persistent connection. Good for occasional updates or when you don't mind the overhead.

typescript

// Mobile side (simplified)
setInterval(async () => {
  const { latitude, longitude } = await getCurrentLocation();
  await fetch('https://api.yourdomain.com/location/update', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      rideId: currentRideId,
      lat: latitude,
      lng: longitude,
      timestamp: Date.now(),
      accuracy: gpsAccuracy,
    }),
  });
}, 3000); // Every 3 seconds

WebSocket approach: Lower latency, reuses a single connection. More efficient for frequent updates. Requires a persistent connection handler and graceful reconnection logic.

typescript

// WebSocket server (Node.js / NestJS example)
@WebSocketGateway()
export class LocationGateway {
  @SubscribeMessage('location:update')
  handleLocationUpdate(
    @ConnectedSocket() client: Socket,
    @MessageBody() data: LocationUpdate,
  ) {
    // data: { rideId, lat, lng, timestamp, accuracy }
    // Emit to validation/ingestion service
    this.locationService.ingestUpdate(data);
  }
}

In production, I usually recommend WebSocket for active rides and HTTP for periodic check-ins. Most ride-hailing platforms use WebSocket because the latency difference is noticeable to users.

Validate and smooth — Filtering noisy and impossible data

Raw GPS data is messy. A user sitting still might report coordinates that jitter by 10–30 meters. A momentary network glitch could report an impossible jump—your driver suddenly 5 km away. Your validation layer catches these before they reach storage.

Here's what you typically validate:

  • Impossible jumps: If the last update was at coordinates (10.5, 15.2) and this one is at (50.1, 60.3) with only 3 seconds elapsed, reject it.
  • Accuracy threshold: If the GPS accuracy is worse than 100 meters, consider it unreliable.
  • Timestamp sanity: Reject updates that are older than your current latest or far in the future.
  • Geofence checks: If the ride is in Colombo, a sudden update from Singapore is obviously wrong.
typescript

// Validation service
export class LocationValidator {
  private readonly MAX_SPEED_MPS = 50; // 180 km/h
  private readonly MAX_ACCURACY_M = 100;

  validate(
    current: LocationPoint,
    previous: LocationPoint | null,
  ): { valid: boolean; reason?: string } {
    // Check accuracy
    if (current.accuracy > this.MAX_ACCURACY_M) {
      return { valid: false, reason: 'Accuracy too poor' };
    }

    // Check timestamp freshness
    if (current.timestamp < Date.now() - 30000) {
      return { valid: false, reason: 'Stale update' };
    }

    if (!previous) {
      return { valid: true };
    }

    // Check for impossible jumps
    const distanceM = this.haversineDistance(
      previous.lat,
      previous.lng,
      current.lat,
      current.lng,
    );
    const timeSec = (current.timestamp - previous.timestamp) / 1000;
    const speedMps = distanceM / timeSec;

    if (speedMps > this.MAX_SPEED_MPS) {
      return { valid: false, reason: 'Impossible speed' };
    }

    return { valid: true };
  }

  private haversineDistance(
    lat1: number,
    lng1: number,
    lat2: number,
    lng2: number,
  ): number {
    const R = 6371000; // Earth radius in meters
    const dLat = ((lat2 - lat1) * Math.PI) / 180;
    const dLng = ((lng2 - lng1) * Math.PI) / 180;
    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos((lat1 * Math.PI) / 180) *
        Math.cos((lat2 * Math.PI) / 180) *
        Math.sin(dLng / 2) ** 2;
    const c = 2 * Math.asin(Math.sqrt(a));
    return R * c;
  }
}

After validation, you might apply smoothing—averaging coordinates over a short window or using a Kalman filter to reduce jitter. This makes the movement appear more fluid on the client side.

Store latest and history — Redis + time-series database

Once an update passes validation, you need to store it in two places:

Latest location in Redis: For instant access. When a rider opens the app or a dispatcher pulls up a ride detail, they need the current position immediately. Redis with a short TTL (time to live) ensures you always have the freshest point.

typescript

// Store in Redis
async storeLatestLocation(rideId: string, location: LocationPoint) {
  const key = `ride:${rideId}:latest`;
  await this.redis.setex(
    key,
    300, // 5 minutes TTL
    JSON.stringify(location),
  );
}

// Retrieve for display
async getLatestLocation(rideId: string): Promise {
  const data = await this.redis.get(`ride:${rideId}:latest`);
  return data ? JSON.parse(data) : null;
}

Historical data in a time-series store: For analytics, replays, and audits. PostgreSQL with a hypertable (via TimescaleDB) or a dedicated time-series database like InfluxDB or Timescale works well. You write every validated update here, not just the latest.

typescript

// Store history in TimescaleDB / PostgreSQL
async storeLocationHistory(
  rideId: string,
  locations: LocationPoint[],
) {
  const query = `
    INSERT INTO ride_locations (ride_id, latitude, longitude, accuracy, recorded_at)
    VALUES ${locations.map(() => '($1, $2, $3, $4, $5)').join(', ')}
  `;
  // Flatten the array of values for the parameterized query
  const values = locations.flatMap((loc) => [
    rideId,
    loc.lat,
    loc.lng,
    loc.accuracy,
    new Date(loc.timestamp),
  ]);

  await this.db.query(query, values);
}

The time-series store scales better for this use case because it's optimized for append-heavy workloads and time-range queries. You can quickly answer "Show me this driver's path over the last hour" without scanning a general-purpose database.

Fan out to watchers — Sending updates only to relevant clients

This is where Kafka and WebSockets shine together. You can't afford to broadcast every driver's location to every connected user. You need to be surgical: send updates only to the users actually watching that ride.

Here's the flow:

  1. A validated location update is published to a Kafka topic (e.g., location-updates).
  2. A fan-out service subscribes to that topic and looks up which users are watching the ride.
  3. For each watcher, send the update over their WebSocket connection.
typescript

// Kafka producer (when location is validated)
async publishLocationUpdate(location: LocationPoint) {
  await this.kafka.send({
    topic: 'location-updates',
    messages: [
      {
        key: location.rideId,
        value: JSON.stringify(location),
        timestamp: Date.now().toString(),
      },
    ],
  });
}

// Kafka consumer (fan-out service)
async startLocationConsumer() {
  const consumer = this.kafka.consumer({ groupId: 'location-fanout' });
  await consumer.subscribe({ topic: 'location-updates' });
  await consumer.run({
    eachMessage: async ({ message }) => {
      const location = JSON.parse(message.value.toString());
      const watchers = this.getWatchersForRide(location.rideId);

      for (const watcher of watchers) {
        this.io.to(`ride:${location.rideId}:${watcher.userId}`).emit(
          'location:update',
          location,
        );
      }
    },
  });
}

To track watchers, maintain a simple in-memory map or use Redis sets:

typescript

// Track who's watching what
async addWatcher(rideId: string, userId: string) {
  await this.redis.sadd(`watchers:ride:${rideId}`, userId);
}

async removeWatcher(rideId: string, userId: string) {
  await this.redis.srem(`watchers:ride:${rideId}`, userId);
}

async getWatchersForRide(rideId: string): Promise {
  return this.redis.smembers(`watchers:ride:${rideId}`);
}

This approach scales because each consumer instance can handle thousands of rides. Kafka partitions by rideId, so a single ride's updates always go to the same partition, preventing ordering issues.

Common mistakes

  • Broadcasting to all users: Sending every location to every connected client kills your infrastructure. Always filter by relevance (who is actually watching).
  • Skipping validation: Trusting client-side GPS directly leads to garbage on your map and messed-up analytics. Validate server-side every time.
  • Only storing the latest: You'll want historical data for replays, debugging, and proof of delivery. Store everything in a time-series DB.
  • Ignoring network resilience: Mobile apps lose connections. Implement exponential backoff, reconnection logic, and local queuing so updates aren't silently dropped.
  • Underestimating accuracy variance: A 50-meter jump might be real (tunnel exit) or noise (multipath reflection). Use smoothing, not rejection alone.

Key takeaways

  • Ingest with WebSockets for low latency: HTTP works but WebSocket is smoother for real-time vehicle tracking.
  • Validate before storage: Check accuracy, detect impossible jumps, and sanitize timestamps to keep your data trustworthy.
  • Split storage by access pattern: Redis for latest, time-series DB for history. This gives you speed and scalability.
  • Use Kafka to decouple ingest from fan-out: Producers push updates; consumers decide who gets them. This scales horizontally.
  • Fan out surgically: Only send updates to users actively watching a ride. Use in-memory maps or Redis sets to track watchers.
  • Smooth before display: Apply light smoothing (moving average or Kalman filter) to reduce the jittery feeling users see.
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