---
title: "IoT Fleet Tracking Over WebSockets: Architecture for 100K Devices"
description: "GPS trackers, sensors, and connected vehicles generate a constant stream of telemetry. Learn how to architect a fleet tracking system that scales to 100,000 devices with WebSockets, filtered subscriptions, and smart QoS choices."
excerpt: "GPS trackers, sensors, and connected vehicles generate a constant stream of telemetry. Here is how to architect a fleet tracking system that scales."
date: '2026-03-10'
readTime: 9 min read
category: Architecture
---

Fleet tracking is one of the most demanding real-time workloads you can build. You have a large number of devices, each publishing position updates at a high rate, and a much smaller number of dashboards that need to display a subset of those devices in real time. The architecture has to handle constant inbound telemetry, selective outbound delivery, variable device connectivity, and occasional high-priority alert messages, all at the same time.

This post walks through a practical architecture for fleet tracking at the 100,000-device scale, including topic design, QoS choices, reconnection handling, and how to keep your infrastructure from becoming a bottleneck.

## The Core Challenge

Fleet tracking has a few properties that make it harder than most real-time workloads.

**Volume.** A fleet of 100,000 vehicles publishing GPS positions every 5 seconds generates 20,000 messages per second. That is constant, not bursty. Your infrastructure needs to sustain that throughput indefinitely, not just survive a spike.

**Fanout asymmetry.** A single dispatcher dashboard might be watching 50 vehicles. Another might be watching 200. A fleet manager wants an overview of all 100,000. The delivery requirements vary enormously between subscribers, but the ingest pipeline is the same for all devices.

**Variable connectivity.** Fleet devices are not sitting on a stable office Wi-Fi connection. They are in tunnels, switching between cellular towers, parked in areas with poor signal, and sometimes simply powered off for hours at a time. Your architecture needs to handle reconnections gracefully without losing critical events.

**Mixed message criticality.** A GPS position update that is 10 seconds old is almost worthless. A geofence breach alert from 10 seconds ago is still very much worth delivering. These two message types need different handling.

## Topic Design: Fleet vs Vehicle

The first architecture decision is how to organize the fleet. There are two main patterns.

**One room per vehicle:** Each device publishes into its own room, for example `vehicle-v_abc123`. Dashboards subscribe to the individual rooms for the vehicles they are tracking.

**One room per fleet with filtered subscriptions:** All vehicles in a fleet publish to a shared `positions` topic in one room, for example `fleet-f_xyz`, tagging each publish with the vehicle id as a filter. Dashboards subscribe to the topic with a filter list specifying which vehicle ids they want updates for.

For most workloads, the filtered subscription approach is better. It keeps the subscription count manageable. A dashboard watching 200 vehicles opens one subscription with 200 filters, rather than 200 individual subscriptions. Subscription overhead adds up: connection tracking, heartbeat management, and routing table size all scale with subscription count. Topic names are single tokens (no `/`), so the hierarchy is exactly `app/room/topic` and the filter carries the per-vehicle routing key. NoLag caps a subscription at 100 filters, so a dashboard watching more than that opens a second subscription or narrows its view.

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'

const client = NoLag(DEVICE_TOKEN)
await client.connect()

// Create the app and the room first; the slug you get back has a random suffix
const room = client.setApp(APP_SLUG).setRoom('fleet-f_xyz')

// Device publishes its position, tagged with its vehicle id as the filter
room.emit(
  'positions',
  {
    vehicleId: 'v_abc123',
    lat: -33.8688,
    lng: 151.2093,
    speed: 62,
    heading: 287,
    timestamp: Date.now(),
  },
  { qos: 0, filter: 'v_abc123' }, // fire and forget for position updates
)
```
```typescript [Dashboard]
import { NoLag } from '@nolag/js-sdk'

const client = NoLag(DASHBOARD_TOKEN)
await client.connect()
const room = client.setApp(APP_SLUG).setRoom('fleet-f_xyz')

// Dashboard subscribes to positions for visible vehicles only (max 100 filters)
room.subscribe('positions', {
  filters: visibleVehicleIds,
})

room.on('positions', (position, meta) => updateVehicleMarker(position, meta.filter))

// As the map pans, swap the filter set without resubscribing
room.setFilters('positions', nowVisibleVehicleIds)
```

One rule to keep in mind: a subscriber with no filters is a wildcard and receives everything on the topic, and a publish with no filter reaches only those wildcard subscribers. Filters are routing, not a privacy boundary. The exception to the shared-room design is very large fleets where different parts of the fleet are managed by different teams. In that case, per-sub-fleet rooms make access control simpler, with each team's actors only granted access to their own fleet's room.

## QoS Choices for Fleet Data

Not all fleet messages have the same delivery requirements. Getting QoS right is important both for reliability and for not overloading your infrastructure with unnecessary acknowledgment traffic. On NoLag the QoS level you pass with a publish (0, 1 or 2, default 1) applies to the broker's internal MQTT hop; the WebSocket leg between the device and the broker has an optional `published` acknowledgement, surfaced as the emit callback, and no resend.

**QoS 0 (fire and forget) for position telemetry.** GPS positions are high-frequency and time-sensitive. A position from 30 seconds ago has almost no value. If a packet drops, the next position update will arrive within seconds anyway. Using QoS 1 for position updates would mean acknowledging and retrying thousands of messages per second inside the broker, most of which would be stale by the time they are retried. QoS 0 is the right choice.

**QoS 1 (at least once) for geofence alerts.** When a vehicle breaches a geofence, that event needs to be delivered. Missing a geofence breach can mean a compliance violation or a missed theft alert. QoS 1 has the broker hop acknowledge the message and retry it if that delivery fails, and the emit callback tells the device the broker accepted it.

```typescript [TypeScript]
// Geofence breach: use QoS 1 and wait for the broker's acknowledgement
room.emit(
  'alerts',
  {
    type: 'geofence_breach',
    vehicleId: 'v_abc123',
    geofenceId: 'zone_restricted_01',
    breachType: 'exit',
    timestamp: Date.now(),
  },
  { qos: 1, filter: 'v_abc123' },
  (err) => {
    if (err) console.error('alert not accepted by the broker', err)
  },
)
```

Separating alerts onto their own topic also lets dashboards manage two subscriptions with different characteristics: a high-volume QoS 0 feed for position updates, and a low-volume QoS 1 feed for alerts that demands attention.

## Reconnection and Replay for Spotty Devices

Fleet devices lose connectivity constantly. A delivery truck going through a basement loading dock, a train in a tunnel, a vehicle switching between cellular providers in a rural area. The connection drops and eventually comes back.

For position data, reconnection is simple: the device reconnects and starts publishing again. No replay needed. The dashboard gap-fills naturally as new positions arrive.

For alert data, it is different. If a geofence breach happened while the device was offline, the device needs to publish that event when it reconnects. The device should buffer critical events locally and flush them on reconnect, publishing them with their original timestamps so the backend can process them in the correct order.

```typescript [TypeScript]
import type { RoomContext } from '@nolag/js-sdk'

type AlertEvent = {
  type: string
  vehicleId: string
  geofenceId?: string
  timestamp: number
}

// Device-side: buffer critical events during outage
class EventBuffer {
  private queue: AlertEvent[] = []

  enqueue(event: AlertEvent) {
    this.queue.push(event)
    this.persistToStorage() // survive process restarts
  }

  flush(room: RoomContext) {
    while (this.queue.length > 0) {
      const event = this.queue[0]
      room.emit('alerts', event, { qos: 1, filter: event.vehicleId })
      this.queue.shift()
    }
  }

  private persistToStorage() {
    // write this.queue to disk
  }
}

const buffer = new EventBuffer()
client.on('reconnect', () => buffer.flush(room))
```

On the dashboard side there is no replay to lean on. NoLag replays missed messages only to load-balanced worker groups with persistent sessions on the hosted platform (see [replay](/docs/concepts/replay)); a dashboard that reconnects gets its subscriptions restored and nothing it missed, and there is no protocol call to request messages from a sequence number. The durable copy of every alert therefore belongs in your backend: a service subscribed to `alerts` writes each one to a database, and the dashboard reads the open alerts from there on load. A [hydration webhook](/docs/concepts/webhooks) makes the load step automatic: when a dashboard subscribes to `alerts`, the broker calls your webhook and delivers its response to the client as a `hydration` event, so the current alert list arrives on the same connection as the live feed.

## Scaling Considerations

At 100,000 devices publishing every 5 seconds, the numbers look like this: 20,000 inbound messages per second, each message fanout to potentially hundreds of dashboard subscriptions. If each dashboard is watching 50 vehicles out of 100,000, the filter match rate is 0.05%. Each filter is its own routing key on the broker, so a position tagged `v_abc123` is only delivered to subscriptions that asked for `v_abc123` (and to wildcard subscribers); nothing is evaluated per message per dashboard. The infrastructure needs to do that routing fast and cheaply.

A few design choices help at this scale:

- **Keep payloads small.** A GPS position needs only lat, lng, speed, heading, and a timestamp. That is under 100 bytes. Do not attach full vehicle records or lookup data to telemetry messages.
- **Let the SDK's binary encoding work for you.** NoLag SDKs encode every frame as MessagePack, so a numeric-heavy position is already smaller on the wire than the same object as JSON text. Keep field names short and values numeric to get the most from it.
- **Separate telemetry from alerts.** Position updates and geofence alerts should be on different topics with different QoS. Mixing them makes it impossible to give alerts an acknowledged hop without paying for it on every position.
- **Limit dashboard subscription scope.** A dashboard watching all 100,000 vehicles is the worst case for fanout. Design the UX to encourage focused views, for example by region or by job, so that each subscription filter list stays manageable and under the 100-filter cap.

## How NoLag Approaches IoT Workloads

NoLag's infrastructure is designed for asymmetric fanout workloads like fleet tracking. Filters are routing keys on the broker, so subscribers only receive what they asked for and a message that no dashboard is watching is never transmitted. QoS levels are configurable per message, so you can mix fire-and-forget telemetry and acknowledged alerts on the same connection without any special configuration.

Dashboard reconnects are handled by restoring subscriptions server-side, and a `reconnect` event on the client is your cue to refresh anything stateful. For a fleet manager who closes their laptop and comes back in the morning, pair that with a hydration webhook so the open-alerts list arrives with the subscription, and keep the durable record of alerts in your own store.

For device-side connectivity, the SDK handles exponential backoff reconnection automatically. Devices that lose signal and regain it will reconnect and resume publishing without any application-level retry code, and the event buffer above covers the alerts raised while they were dark.
