ESP32 and Arduino

ESP32 GPS Tracker MQTT: NEO-6M Positions on a Live Web Map

Read a NEO-6M GPS module with the ESP32, publish position, speed and fix quality over MQTT only when the device moves, and display the track live on a Leaflet map over secure WebSocket.

ESP32 with a NEO-6M GPS module sending positions to a live web map in an esp32 gps tracker mqtt project
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

An ESP32 GPS tracker MQTT project shows a moving device on a map in near real time with very little code. In this tutorial you will read a NEO-6M module with TinyGPSPlus, publish position, speed and fix quality to broker.connectmq.com only when the device actually moves, and draw the live track on a Leaflet web map that subscribes over secure WebSocket.

How the Pieces Fit Together

The data flow has three parts:

  1. NEO-6M to ESP32. The GPS module streams NMEA sentences over a UART at 9600 baud. TinyGPSPlus parses them into latitude, longitude, speed and more.
  2. ESP32 to broker. The ESP32 publishes a compact JSON position over MQTT on TCP port 1883 (or TLS 8883 in production).
  3. Broker to browser. A static web page connects with MQTT.js over secure WebSocket on port 8084, path /mqtt, and moves a marker on the map.

Because MQTT decouples publisher and subscriber, you can open the map on several devices at once, and a retained last-position message means a freshly opened map shows the tracker immediately instead of waiting for the next fix. If you are new to browser MQTT, MQTT over WebSocket explains the transport.

Parts and Wiring

  • An ESP32 development board.
  • A NEO-6M GPS module with its ceramic patch antenna (the common GY-NEO6MV2 board).
  • Libraries: TinyGPSPlus by Mikal Hart and PubSubClient.
NEO-6M pin ESP32 pin Notes
VCC 3V3 (or 5V if the board has a regulator) Check your module; most GY-NEO6MV2 boards accept both
GND GND
TX GPIO 16 (UART2 RX) GPS data into the ESP32
RX GPIO 17 (UART2 TX) Only needed to send configuration commands

The ESP32 has three hardware UARTs, so the GPS gets its own port and Serial stays free for debugging. Do not use software serial.

Getting a First Fix

A NEO-6M starting cold needs a clear view of the sky and can take several minutes to produce its first fix. Indoors it may never get one. The blue LED on most modules blinks once per second when a fix is valid. Practical tips:

  • Do the first test outdoors or next to a window, antenna facing up.
  • Keep the ESP32 and its Wi-Fi antenna a few centimetres away from the GPS antenna.
  • The small backup battery on the module keeps almanac data so the next start is much faster. If restarts are always slow, the battery may be flat.

Publishing Strategy: Move-Based, Not Clock-Based

Publishing every second wastes data while parked and floods subscribers. The sketch publishes when:

  • the device has moved more than a threshold distance (25 m by default) since the last publish, or
  • a heartbeat interval has passed (60 s), so the map knows the tracker is alive while stationary.
Topic Retained Payload
connectmq/demo/gps6j1v/position Yes {"lat":41.008238,"lng":28.978359,"speed":32.4,"course":118.2,"alt":44.1,"sats":8,"hdop":1.1}
connectmq/demo/gps6j1v/status Yes online, offline, no-fix

The ESP32 GPS Tracker MQTT Sketch

#include <WiFi.h>
#include <PubSubClient.h>
#include <TinyGPSPlus.h>

const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST     = "broker.connectmq.com";
const uint16_t MQTT_PORT  = 1883;
const char* PREFIX        = "connectmq/demo/gps6j1v";   // use your own random id

const int GPS_RX = 16, GPS_TX = 17;
const double MIN_MOVE_M    = 25.0;
const uint32_t HEARTBEAT_MS = 60000;
const uint32_t MIN_GAP_MS   = 2000;     // never faster than this

TinyGPSPlus gps;
HardwareSerial gpsSerial(2);
WiFiClient   net;
PubSubClient mqtt(net);
String clientId, tPosition, tStatus;

double lastLat = 0, lastLng = 0;
bool havePublished = false;
uint32_t lastPublishMs = 0;
bool fixReported = false;

void connectWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) {
    delay(200);
    while (gpsSerial.available()) gps.encode(gpsSerial.read());   // keep parsing
  }
  Serial.printf("Wi-Fi OK, IP %s\n", WiFi.localIP().toString().c_str());
}

void connectMqtt() {
  while (!mqtt.connected()) {
    if (mqtt.connect(clientId.c_str(), tStatus.c_str(), 1, true, "offline")) {
      mqtt.publish(tStatus.c_str(), "online", true);
      Serial.println("MQTT connected");
    } else {
      Serial.printf("MQTT failed, state %d\n", mqtt.state());
      delay(3000);
    }
  }
}

void publishPosition() {
  char payload[220];
  snprintf(payload, sizeof(payload),
           "{\"lat\":%.6f,\"lng\":%.6f,\"speed\":%.1f,\"course\":%.1f,"
           "\"alt\":%.1f,\"sats\":%lu,\"hdop\":%.1f}",
           gps.location.lat(), gps.location.lng(),
           gps.speed.isValid() ? gps.speed.kmph() : 0.0,
           gps.course.isValid() ? gps.course.deg() : 0.0,
           gps.altitude.isValid() ? gps.altitude.meters() : 0.0,
           (unsigned long)gps.satellites.value(),
           gps.hdop.isValid() ? gps.hdop.hdop() : 99.9);
  if (mqtt.publish(tPosition.c_str(), payload, true)) {
    lastLat = gps.location.lat();
    lastLng = gps.location.lng();
    lastPublishMs = millis();
    havePublished = true;
    Serial.println(payload);
  }
}

void setup() {
  Serial.begin(115200);
  gpsSerial.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);

  clientId  = "esp32-gps-" + String((uint32_t)ESP.getEfuseMac(), HEX);
  tPosition = String(PREFIX) + "/position";
  tStatus   = String(PREFIX) + "/status";

  connectWifi();
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setBufferSize(512);
  connectMqtt();
}

void loop() {
  while (gpsSerial.available()) gps.encode(gpsSerial.read());

  if (WiFi.status() != WL_CONNECTED) connectWifi();
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  bool fixValid = gps.location.isValid() && gps.location.age() < 3000;
  if (!fixValid) {
    if (fixReported || millis() - lastPublishMs > HEARTBEAT_MS) {
      mqtt.publish(tStatus.c_str(), "no-fix", true);
      fixReported = false;
      lastPublishMs = millis();
    }
    if (millis() > 10000 && gps.charsProcessed() < 10) {
      Serial.println("No NMEA data: check GPS TX -> GPIO 16 wiring");
    }
    return;
  }
  if (!fixReported) {
    mqtt.publish(tStatus.c_str(), "online", true);
    fixReported = true;
  }

  uint32_t sinceLast = millis() - lastPublishMs;
  if (sinceLast < MIN_GAP_MS) return;

  double moved = havePublished
      ? TinyGPSPlus::distanceBetween(lastLat, lastLng, gps.location.lat(), gps.location.lng())
      : 1e9;
  if (moved >= MIN_MOVE_M || sinceLast >= HEARTBEAT_MS) {
    publishPosition();
  }
}

The gps.location.age() check matters: TinyGPSPlus keeps the last valid location even after the fix is lost, so without it you would keep publishing a stale position as if it were current.

The Live Web Map

Save this as map.html and open it in a browser. It uses Leaflet with OpenStreetMap tiles and MQTT.js over secure WebSocket. Change the prefix to match your sketch.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>ESP32 GPS tracker</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
  <style>html, body, #map { height: 100%; margin: 0; }</style>
</head>
<body>
  <div id="map"></div>
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
  <script>
    const PREFIX = "connectmq/demo/gps6j1v";
    const map = L.map("map").setView([0, 0], 2);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution: "&copy; OpenStreetMap contributors"
    }).addTo(map);

    const marker = L.marker([0, 0]).addTo(map);
    const track = L.polyline([], { weight: 4 }).addTo(map);
    let first = true;

    const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
      clientId: "gps-map-" + Math.random().toString(16).slice(2, 10),
      protocolVersion: 5,
      clean: true
    });

    client.on("connect", () => client.subscribe(PREFIX + "/#", { qos: 0 }));

    client.on("message", (topic, payload) => {
      if (topic === PREFIX + "/status") {
        marker.bindTooltip("Status: " + payload.toString()).openTooltip();
        return;
      }
      if (topic !== PREFIX + "/position") return;
      const p = JSON.parse(payload.toString());
      const pos = [p.lat, p.lng];
      marker.setLatLng(pos).bindPopup(
        `Speed ${p.speed} km/h<br>Satellites ${p.sats}<br>HDOP ${p.hdop}`);
      track.addLatLng(pos);
      if (first) { map.setView(pos, 16); first = false; } else { map.panTo(pos); }
    });
  </script>
</body>
</html>

The retained position message arrives as soon as the page subscribes, so the marker jumps to the last known location immediately. The polyline then grows as new positions come in. Leaflet's options for markers, popups and polylines are covered in the Leaflet documentation.

Testing the ESP32 GPS Tracker MQTT Flow

Watch the raw messages alongside the map:

mosquitto_sub -h broker.connectmq.com -p 1883 -i gps-view-6j1v -t "connectmq/demo/gps6j1v/#" -v

Walk around the block with the tracker on a phone hotspot and the track draws itself. Location is personal data, and the public area is readable by everyone who knows or guesses the topic, so treat this as a short experiment and never track people there. The public MQTT test broker page explains what the public area is for.

Keeping Payloads Small and Useful

Every field in the position message has a purpose, and it is worth resisting the urge to add more. Six decimal places of latitude and longitude resolve to roughly ten centimetres, which is already finer than a NEO-6M can deliver, so more digits only add bytes. Speed and course let the map rotate an icon or show a speed label without computing them from consecutive points. Satellite count and HDOP describe fix quality: a consumer can grey out the marker, or ignore a point entirely, when HDOP is high or fewer than five satellites are in use.

If you later move to a cellular modem where every kilobyte costs money, shorten the keys (la, lo, sp) or send a fixed binary layout, and keep the move-based publishing. On a battery-powered tracker, the GPS module itself is often the largest consumer; the NEO-6M supports power-saving modes configured through UBX commands on its RX pin, which is why that wire is in the table even though the basic sketch never sends anything.

Troubleshooting

  1. No NMEA data at all. TX and RX are swapped, or the baud rate is wrong. The NEO-6M defaults to 9600.
  2. Data but no fix. Indoors, or the antenna is shadowed. Go outside and wait several minutes on a cold start.
  3. Position jumps around while parked. Normal GPS jitter. Raise MIN_MOVE_M, or skip fixes with HDOP above about 2.5.
  4. Map stays at 0,0. The browser cannot reach port 8084, or the prefix in map.html does not match. Check the browser console.
  5. Gaps when driving. Wi-Fi is not available on the road. A real vehicle tracker needs a cellular modem; the MQTT code stays the same.

Production Setup and Privacy

For real tracking, create a broker at app.connectmq.com, switch the ESP32 to WiFiClientSecure on port 8883 with the ISRG Root X1 certificate, and connect with YOUR_CLIENT_ID, YOUR_USERNAME and YOUR_PASSWORD. Do not embed production credentials in a public web page: anyone can read them from the page source. Serve the map from a private location, or put a small backend between the broker and public viewers. In the ConnectMQ portal, the live topic explorer shows each position update as it arrives. To add a timestamp to every fix, either use GPS time from gps.date and gps.time or follow the ESP32 NTP timestamp guide. For trackers that hear BLE tags nearby, the ESP32 BLE MQTT gateway is a natural extension.

Track Your Fleet on ConnectMQ

A tracker needs a broker that devices and browsers can both reach securely. ConnectMQ is a cloud MQTT broker with MQTT over TLS on 8883 and secure WebSocket on 8084, retained messages for last known positions, and a free Serverless plan that needs no credit card. Connect your tracker and map, then browse the ESP32 IoT tutorial series for more device projects.

Frequently Asked Questions

How often should an ESP32 GPS tracker publish over MQTT?

Publish on movement plus a heartbeat rather than on a fixed fast clock. A 25 m threshold with a one-minute heartbeat suits walking and slow vehicles.

Why use a retained message for the position?

A new subscriber, such as a map that just opened, immediately receives the last known position instead of waiting for the device to move.

Can the web map connect to the broker directly?

Yes. Browsers use MQTT over secure WebSocket, here wss://broker.connectmq.com:8084/mqtt. For private data, avoid exposing credentials in a public page.

Does the NEO-6M work indoors?

Usually not reliably. GPS signals are weak and need a view of the sky. Test outdoors or by a window.

Related articles

ESP-IDF MQTT Example: Build an esp-mqtt Client from Scratch

Build an ESP-IDF project from an empty folder with the esp-mqtt component: Wi-Fi station setup, the MQTT event handler, publish and subscribe, MQTT 5 properties and TLS with the certificate bundle.

8 min read

Using esp-mqtt MQTT 5 Features on ESP32 with ESP-IDF

A hands-on ESP-IDF project that enables MQTT 5.0 in esp-mqtt and uses user properties, message expiry, topic aliases, session expiry and response topics against a real MQTT 5 broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.