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

Throttle GPS updates: a practical Kafka & WebSockets guide

More updates isn't better tracking.

Expona Admin13 min read

Real-time GPS tracking sounds simple until your database fills with thousands of redundant location pings per second and your WebSocket connections melt under the load. In this practical guide, we'll explore how to throttle GPS updates—a system that sends location data based on distance and time, reduces noise when vehicles are stationary, batches historical uploads, and interpolates smoothly on the client—so you get accurate tracking without melting your infrastructure.

The problem: why naive GPS streaming fails

Most GPS devices emit a location event every 1–2 seconds, regardless of whether the truck moved 5 meters or stayed parked. When you have 1,000 active vehicles, that's 1,000–2,000 messages per second flowing through Kafka, then fanned out to dozens of tracking dashboards via WebSocket. Your database grows, your network saturates, and your cloud bill becomes painful.

The solution isn't to ignore data—it's to be intentional about which updates matter. A cargo vehicle that hasn't moved shouldn't trigger the same volume of events as one actively making deliveries. A minor jitter in coordinates while stopped should be filtered, not stored. And historical data—the full route from yesterday—doesn't need to stream in real-time.

Send updates based on distance and time

The most effective throttling strategy combines two metrics: only emit an update if the vehicle has moved more than a minimum distance threshold or a maximum time has elapsed since the last update.

Here's a producer service that runs on each vehicle's GPS device or a companion mobile app:

typescript
interface LocationUpdate {
  vehicleId: string;
  latitude: number;
  longitude: number;
  timestamp: number;
  speed: number;
}

interface ThrottleConfig {
  distanceThresholdMeters: number;
  timeThresholdMs: number;
}

class LocationThrottler {
  private lastEmitted: Map<string, LocationUpdate> = new Map();
  private config: ThrottleConfig;

  constructor(config: ThrottleConfig) {
    this.config = config;
  }

  private haversineDistance(
    lat1: number,
    lon1: number,
    lat2: number,
    lon2: number
  ): number {
    const R = 6371e3; // metres
    const φ1 = (lat1 * Math.PI) / 180;
    const φ2 = (lat2 * Math.PI) / 180;
    const Δφ = ((lat2 - lat1) * Math.PI) / 180;
    const Δλ = ((lon2 - lon1) * Math.PI) / 180;

    const a =
      Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
      Math.cos(φ1) *
        Math.cos(φ2) *
        Math.sin(Δλ / 2) *
        Math.sin(Δλ / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

    return R * c;
  }

  shouldEmit(vehicleId: string, newLocation: LocationUpdate): boolean {
    const last = this.lastEmitted.get(vehicleId);

    if (!last) {
      return true; // First location always emits
    }

    const distance = this.haversineDistance(
      last.latitude,
      last.longitude,
      newLocation.latitude,
      newLocation.longitude
    );

    const timeDelta = newLocation.timestamp - last.timestamp;

    // Emit if moved more than threshold OR time threshold exceeded
    return (
      distance >= this.config.distanceThresholdMeters ||
      timeDelta >= this.config.timeThresholdMs
    );
  }

  process(location: LocationUpdate): LocationUpdate | null {
    if (this.shouldEmit(location.vehicleId, location)) {
      this.lastEmitted.set(location.vehicleId, location);
      return location;
    }
    return null;
  }
}

// Usage
const throttler = new LocationThrottler({
  distanceThresholdMeters: 50, // Only emit if moved 50m+
  timeThresholdMs: 30000, // Or 30 seconds have passed
});

// In your GPS event handler
const raw = { vehicleId: 'V001', latitude: 40.71, longitude: -74.01, timestamp: Date.now(), speed: 45 };
const toEmit = throttler.process(raw);
if (toEmit) {
  // Send to Kafka
  await producer.send({
    topic: 'vehicle-locations',
    messages: [{ value: JSON.stringify(toEmit) }],
  });
}
```

<p>Tuning these thresholds depends on your use case. A ride-hailing service might use 20m and 5s; a delivery tracking system might use 100m and 60s. Start conservative and adjust based on UX feedback and infrastructure load.</p>

<h2 id="reduce-frequency-when-stationary">Reduce frequency when stationary</h2>

<p>A parked truck shouldn't occupy the same event stream as an active one. Implement dynamic throttling that detects stationarity and reduces the cadence further.</p>

<pre><code class="language-typescript">interface StatefulThrottler extends LocationThrottler {
  stationaryThresholdMs: number;
  stationaryDistanceMeters: number;
}

class AdaptiveLocationThrottler {
  private lastEmitted: Map<string, LocationUpdate> = new Map();
  private stationaryStart: Map<string, number> = new Map();
  private config: {
    distanceThresholdMeters: number;
    timeThresholdMs: number;
    stationaryDistanceMeters: number;
    stationaryTimeThresholdMs: number;
  };

  constructor(config: any) {
    this.config = config;
  }

  private haversineDistance(
    lat1: number,
    lon1: number,
    lat2: number,
    lon2: number
  ): number {
    const R = 6371e3;
    const φ1 = (lat1 * Math.PI) / 180;
    const φ2 = (lat2 * Math.PI) / 180;
    const Δφ = ((lat2 - lat1) * Math.PI) / 180;
    const Δλ = ((lon2 - lon1) * Math.PI) / 180;

    const a =
      Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
      Math.cos(φ1) *
        Math.cos(φ2) *
        Math.sin(Δλ / 2) *
        Math.sin(Δλ / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }

  process(location: LocationUpdate): LocationUpdate | null {
    const last = this.lastEmitted.get(location.vehicleId);

    if (!last) {
      this.lastEmitted.set(location.vehicleId, location);
      this.stationaryStart.delete(location.vehicleId);
      return location;
    }

    const distance = this.haversineDistance(
      last.latitude,
      last.longitude,
      location.latitude,
      location.longitude
    );
    const timeDelta = location.timestamp - last.timestamp;

    // Check if vehicle is now stationary
    if (distance < this.config.stationaryDistanceMeters && location.speed < 1) {
      const stationarySince = this.stationaryStart.get(location.vehicleId) || location.timestamp;

      // If just became stationary, emit once
      if (!this.stationaryStart.has(location.vehicleId)) {
        this.stationaryStart.set(location.vehicleId, stationarySince);
        this.lastEmitted.set(location.vehicleId, location);
        return location;
      }

      // If stationary, only emit at the longer threshold
      if (location.timestamp - stationarySince >= this.config.stationaryTimeThresholdMs) {
        this.lastEmitted.set(location.vehicleId, location);
        return location;
      }

      return null;
    }

    // Vehicle is moving: use normal thresholds
    this.stationaryStart.delete(location.vehicleId);

    if (
      distance >= this.config.distanceThresholdMeters ||
      timeDelta >= this.config.timeThresholdMs
    ) {
      this.lastEmitted.set(location.vehicleId, location);
      return location;
    }

    return null;
  }
}

// Usage
const adaptiveThrottler = new AdaptiveLocationThrottler({
  distanceThresholdMeters: 50,
  timeThresholdMs: 30000,
  stationaryDistanceMeters: 5,
  stationaryTimeThresholdMs: 120000, // 2 min updates when parked
});
```

<p>This approach drastically cuts message volume during parking windows, pickup waits, and delivery stops—which represent 40–60% of a typical vehicle's day.</p>

<h2 id="batch-history-uploads">Batch history uploads</h2>

<p>Real-time updates via Kafka and WebSockets are best for current position and status. But historical routes—"show me where this truck went yesterday"—should be loaded asynchronously from a database or object store, not streamed live.</p>

<p>Design your system with two paths:</p>

<ul>
<li><strong>Hot path:</strong> Real-time location updates (throttled) → Kafka → WebSocket consumers → live map view</li>
<li><strong>Cold path:</strong> Batch insert of throttled locations into TimescaleDB or similar every 5–10 minutes, queryable for historical playback</li>
</ul>

<pre><code class="language-typescript">class HistoryBatcher {
  private batch: Map<string, LocationUpdate[]> = new Map();
  private flushIntervalMs: number;

  constructor(flushIntervalMs: number = 300000) {
    this.flushIntervalMs = flushIntervalMs;
    this.startPeriodicFlush();
  }

  add(location: LocationUpdate): void {
    const vehicleId = location.vehicleId;
    if (!this.batch.has(vehicleId)) {
      this.batch.set(vehicleId, []);
    }
    this.batch.get(vehicleId)!.push(location);
  }

  private startPeriodicFlush(): void {
    setInterval(() => this.flush(), this.flushIntervalMs);
  }

  private async flush(): Promise<void> {
    if (this.batch.size === 0) return;

    const allLocations: LocationUpdate[] = [];
    for (const locations of this.batch.values()) {
      allLocations.push(...locations);
    }

    try {
      // Bulk insert to TimescaleDB or similar
      await db.query(
        `INSERT INTO location_history (vehicle_id, latitude, longitude, timestamp, speed)
         VALUES ${allLocations.map((_, i) => `($${i * 5 + 1}, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`).join(', ')}`,
        allLocations.flatMap(l => [l.vehicleId, l.latitude, l.longitude, l.timestamp, l.speed])
      );

      console.log(`Flushed ${allLocations.length} historical locations`);
      this.batch.clear();
    } catch (err) {
      console.error('History flush failed:', err);
      // Retry on next cycle
    }
  }

  async flushNow(): Promise<void> {
    await this.flush();
  }
}

// Usage in your Kafka consumer
const batcher = new HistoryBatcher(300000); // Flush every 5 min

consumer.on('message', (message) => {
  const location = JSON.parse(message.value);
  
  // Emit real-time to WebSocket
  broadcastToTracking(location);
  
  // Queue for batch historical insert
  batcher.add(location);
});

This separation means your real-time updates stay snappy while historical queries don't compete for I/O with live dashboards.

Interpolate smoothly on the client

Because you're throttling updates, users will see vehicles "jump" between positions. Interpolation bridges these gaps, creating smooth animation on the map.

typescript
interface InterpolatedTracker {
  vehicleId: string;
  currentLocation: LocationUpdate;
  previousLocation: LocationUpdate | null;
  targetLocation: LocationUpdate;
  startTime: number;
}

class ClientSideInterpolator {
  private trackers: Map<string, InterpolatedTracker> = new Map();
  private animationFrameId: number | null = null;

  onLocationUpdate(location: LocationUpdate): void {
    let tracker = this.trackers.get(location.vehicleId);

    if (!tracker) {
      tracker = {
        vehicleId: location.vehicleId,
        currentLocation: location,
        previousLocation: null,
        targetLocation: location,
        startTime: Date.now(),
      };
      this.trackers.set(location.vehicleId, tracker);
      return;
    }

    // New update: set as target, start interpolation
    tracker.previousLocation = tracker.currentLocation;
    tracker.targetLocation = location;
    tracker.startTime = Date.now();

    if (!this.animationFrameId) {
      this.animateFrame();
    }
  }

  private animateFrame(): void {
    const now = Date.now();
    let anyActive = false;

    for (const tracker of this.trackers.values()) {
      const elapsed = now - tracker.startTime;
      // Interpolate over 2 seconds (adjust to your update cadence)
      const duration = 2000;
      const progress = Math.min(elapsed / duration, 1);

      if (progress < 1) {
        anyActive = true;
        const interpolated = this.lerp(
          tracker.currentLocation,
          tracker.targetLocation,
          progress
        );
        this.updateMapMarker(tracker.vehicleId, interpolated);
      } else if (progress === 1) {
        tracker.currentLocation = tracker.targetLocation;
        this.updateMapMarker(tracker.vehicleId, tracker.currentLocation);
      }
    }

    if (anyActive) {
      this.animationFrameId = requestAnimationFrame(() => this.animateFrame());
    } else {
      this.animationFrameId = null;
    }
  }

  private lerp(
    from: LocationUpdate,
    to: LocationUpdate,
    progress: number
  ): LocationUpdate {
    return {
      vehicleId: from.vehicleId,
      latitude: from.latitude + (to.latitude - from.latitude) * progress,
      longitude: from.longitude + (to.longitude - from.longitude) * progress,
      timestamp: from.timestamp + (to.timestamp - from.timestamp) * progress,
      speed: from.speed + (to.speed - from.speed) * progress,
    };
  }

  private updateMapMarker(vehicleId: string, location: LocationUpdate): void {
    // Update your Leaflet, Mapbox, or Google Maps marker here
    const marker = this.getMarkerForVehicle(vehicleId);
    if (marker) {
      marker.setLatLng([location.latitude, location.longitude]);
    }
  }

  private getMarkerForVehicle(vehicleId: string): any {
    // Placeholder: retrieve from your map instance
    return null;
  }
}

When throttled updates arrive at 30-second intervals, linear interpolation over 2 seconds creates a smooth "motion" that looks natural to the user while keeping server load manageable.

Common mistakes

  • Tuning thresholds by guesswork: Start with production data. Log all raw GPS signals for a day, then measure how many unique "interesting" updates exist at various thresholds. Tune to that real distribution, not an imagined one.
  • Applying the same throttle to all vehicle types: A city delivery van and a long-haul trucker need different cadences. Use vehicle metadata to pick throttle profiles—geography, speed class, priority.
  • Forgetting the edge case of teleportation: If a device reboots or switches networks, GPS can jump 1km suddenly. Add a sanity check: if distance > some max (e.g., 5km in one update), treat it as a new session or error, don't interpolate across it.
  • Stalling on real-time updates when the database fills: Your batch insert rate should match your producer rate. If it doesn't, your queue grows unbounded. Monitor lag; if it grows, increase batch size or flush frequency.
  • Ignoring WebSocket backpressure: If a browser client is slow to consume updates, don't buffer infinitely on your server. Implement a sliding window (e.g., always send the last 10 updates, drop older ones) so slow clients don't cause memory spikes.

Key takeaways

  • Throttle GPS updates based on distance and time together, not just one: emit when distance threshold or time threshold is crossed.
  • Detect stationarity early and apply a longer throttle interval; parked vehicles are cheaper to track than active ones.
  • Keep real-time updates separate from historical routes: stream current positions via Kafka/WebSocket, batch old data into a time-series database asynchronously.
  • Compensate for throttled updates on the client with interpolation: users see smooth motion, not jumps, even though the server sends fewer updates.
  • Tune thresholds against real production data and vehicle types; one size never fits all.
  • Monitor queue lag, database insertion rate, and WebSocket backpressure to catch cascading failures early.
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