← All writing
Cloud & IoT · 10 March 2026 · 3 min read

From GPS tracker to map: vehicle telemetry with Azure IoT Hub, Service Bus and Express.js

A practical architecture for fleet tracking: device identity, routing, consumers that survive duplicates and out-of-order data, storage for positions, and a mobile app on top.
Imron Athoriq Firjatulloh
Backend Engineer · PT Serasi Autoraya (Astra International)
A fleet tracking system has an easy demo and a hard production version. The demo: a tracker posts its position, a server saves it, an app draws it. The production version has to cope with vehicles driving through areas with no signal, trackers that resend everything when they reconnect, and thousands of messages arriving in bursts. During my IoT internship I worked on such a pipeline, built on Azure IoT Hub, Azure Service Bus, Express.js, MongoDB and a Flutter app. This is the architecture and the lessons from it.

The shape of the system

text
GPS tracker --MQTT/HTTPS--> Azure IoT Hub --route--> Service Bus queue/topic | Express.js consumers (Node.js) | MongoDB (latest position + history) | REST API ----> Flutter app (map)
Each box has one job, and the queue in the middle is what makes the system resilient: if the consumers are slow or restarting, messages wait instead of being lost.

Device identity: IoT Hub, not a shared API key

Every tracker is registered in IoT Hub with its own identity and credentials. That gives you per-device authentication, the ability to disable one stolen or faulty device, and device-to-cloud messaging over MQTT, which is far lighter than HTTPS for a device on a mobile connection. Telemetry messages carry the device ID, a GPS fix (lat, lng, speed, heading) and, importantly, the time the fix was recorded, not the time it was sent.

Routing to Service Bus

IoT Hub message routing forwards telemetry to a Service Bus queue or topic. A topic with subscriptions lets several consumers read the same stream independently: one updates positions, one detects trip start and end, one raises alerts, and adding a new one does not touch the others.

Consumers that survive real data

This is where most of the effort goes. Service Bus delivers at least once, so the consumer must be safe to run twice on the same message.
▸
Duplicates. A tracker that reconnects resends its buffer. Use (deviceId, recordedAt) as a unique key and ignore duplicates at the database level.
▸
Out of order. A buffered fix from ten minutes ago can arrive after a fresh one. Store history sorted by recordedAt, and only update the "latest position" if the incoming fix is newer.
▸
Poison messages. A malformed payload should not block the queue forever. After a few delivery attempts, it moves to the dead-letter queue, where you can inspect it.
▸
Complete or abandon explicitly. Complete a message only after it is safely stored. If processing fails, abandon it so it is redelivered.
js
receiver.subscribe({ async processMessage(message) { const fix = parseFix(message.body); // throws on bad payload await positions.updateOne( { vehicleId: fix.vehicleId, recordedAt: fix.recordedAt }, { $setOnInsert: fix }, { upsert: true }, // duplicate -> no-op ); await latest.updateOne( { vehicleId: fix.vehicleId, recordedAt: { $lt: fix.recordedAt } }, { $set: fix }, // only move forward in time ); }, async processError(args) { log.error(args.error); }, });
With the SDK's default receive mode, a thrown error abandons the message and Service Bus retries it; the dead-letter queue catches whatever keeps failing.

Storage

Two collections work well: a small latest collection with one document per vehicle (what the map needs, instantly), and a positions history indexed on (vehicleId, recordedAt) for trip playback and distance. Give history a retention policy (a TTL index, or a time-series collection) so it does not grow forever.

Status is derived, not stored

"Moving", "idle" and "offline" are computed from the latest fix: offline if nothing arrived for 15 minutes, moving if speed is above a small threshold, otherwise idle. Computing it on read means it can never be stale.

The app

The Flutter app polls the REST API (or subscribes to updates) for latest positions and draws markers coloured by status. Keep payloads small: the map needs position, heading, status and a timestamp, not the full history.

What made the difference

Most reliability gains came from boring decisions: per-device identity, a queue between ingest and processing, idempotent consumers, and dead-lettering instead of retry loops. In my case, scaling the cloud architecture this way improved telemetry delivery reliability and event-processing efficiency by about 35%.
My Auction Platform API includes a small telemetry module with the same ideas: 202 Accepted on ingest, asynchronous processing, duplicate and out-of-order handling. You can call it from the API playground on the home page.
© 2026 Imron Athoriq Firjatulloh · Built with React Native & Expo
Home →