An ESP32 NTP MQTT timestamp records when a reading was actually taken, not when it happened to reach the broker. In this tutorial you will sync the ESP32 clock over SNTP, format UTC timestamps in ISO 8601 and epoch milliseconds, and publish them in JSON to broker.connectmq.com, including the edge cases that break naive implementations.
Why MQTT Messages Need Their Own Timestamp
MQTT does not put a timestamp in the packet. A subscriber knows when a message arrived, but that can be very different from when the sensor was read:
- The device buffered readings during a Wi-Fi outage and publishes them in a burst later.
- A persistent session delivered queued QoS 1 messages to a subscriber that was offline.
- A retained message is delivered to a new subscriber hours after it was published.
- Network latency and retries add jitter that matters for fast events.
If your dashboard or database uses arrival time, all four cases produce wrong charts. The fix is simple: let the device stamp each reading at capture time, using a clock synchronised to UTC. The ESP32 has a real-time counter but no battery-backed clock on most boards, so after every boot it starts at 1 January 1970 until NTP corrects it.
Choosing a Timestamp Format
| Format | Example | Size | Best for |
|---|---|---|---|
| ISO 8601 UTC with ms | 2026-10-16T09:41:07.382Z |
24 chars | Human-readable logs, most JSON consumers |
| Epoch seconds | 1792143667 |
10 digits | Compact payloads, coarse telemetry |
| Epoch milliseconds | 1792143667382 |
13 digits | Time-series databases, event ordering |
| Local time with offset | 2026-10-16T11:41:07+02:00 |
25 chars | Displays tied to a single site |
Use UTC on the wire. Convert to local time in the dashboard, where the viewer's time zone is known. Local timestamps on devices cause duplicate or missing hours twice a year when daylight saving changes. The sketch below publishes both ISO 8601 and epoch milliseconds so you can choose downstream.
Prerequisites
- An ESP32 board with Arduino core 3.x (core 2.x also works for this sketch).
- PubSubClient by Nick O'Leary.
- Outbound UDP port 123 allowed on your network for NTP.
mosquitto_subfor testing.
The ESP32 NTP MQTT Timestamp Sketch
#include <WiFi.h>
#include <PubSubClient.h>
#include <time.h>
#include <sys/time.h>
#include "esp_sntp.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* TOPIC = "connectmq/demo/ntp8r2c/telemetry"; // make the id unique
const time_t VALID_EPOCH = 1700000000; // anything earlier means "not synced yet"
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
volatile bool timeSynced = false;
unsigned long lastPublish = 0;
void onTimeSync(struct timeval* tv) {
timeSynced = true;
Serial.printf("NTP sync at epoch %ld\n", (long)tv->tv_sec);
}
// Writes e.g. 2026-10-16T09:41:07.382Z, returns false if the clock is not valid
bool isoTimestamp(char* buf, size_t len, uint64_t* epochMs) {
struct timeval tv;
gettimeofday(&tv, nullptr);
if (tv.tv_sec < VALID_EPOCH) return false;
struct tm utc;
gmtime_r(&tv.tv_sec, &utc);
size_t n = strftime(buf, len, "%Y-%m-%dT%H:%M:%S", &utc);
snprintf(buf + n, len - n, ".%03ldZ", (long)(tv.tv_usec / 1000));
*epochMs = (uint64_t)tv.tv_sec * 1000ULL + tv.tv_usec / 1000;
return true;
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(300);
Serial.printf("Wi-Fi connected, IP %s\n", WiFi.localIP().toString().c_str());
}
void startNtp() {
sntp_set_time_sync_notification_cb(onTimeSync);
configTime(0, 0, "pool.ntp.org", "time.cloudflare.com", "time.google.com");
// Wait up to 15 s for the first sync, but never block forever
struct tm tmp;
if (getLocalTime(&tmp, 15000)) {
Serial.println("Clock is valid");
} else {
Serial.println("NTP not reachable yet, will publish with timeSynced=false");
}
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(clientId.c_str())) {
Serial.println("MQTT connected");
} else {
Serial.printf("MQTT failed, state %d\n", mqtt.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
clientId = "esp32-ntp-" + String((uint32_t)ESP.getEfuseMac(), HEX);
connectWifi();
startNtp();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
connectMqtt();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish < 5000) return;
lastPublish = millis();
// 1. Capture the reading and its timestamp together
float temperature = 21.5f + (esp_random() % 300) / 100.0f;
char iso[32];
uint64_t epochMs = 0;
bool valid = isoTimestamp(iso, sizeof(iso), &epochMs);
// 2. Build the payload
char payload[200];
if (valid) {
snprintf(payload, sizeof(payload),
"{\"ts\":\"%s\",\"tsMs\":%llu,\"timeSynced\":true,\"temperature\":%.2f}",
iso, (unsigned long long)epochMs, temperature);
} else {
snprintf(payload, sizeof(payload),
"{\"uptimeMs\":%lu,\"timeSynced\":false,\"temperature\":%.2f}",
millis(), temperature);
}
// 3. Publish
mqtt.publish(TOPIC, payload);
Serial.println(payload);
}
Subscribe from your computer to see the result:
mosquitto_sub -h broker.connectmq.com -p 1883 -i ts-viewer-8r2c -t "connectmq/demo/ntp8r2c/#" -v
Each line looks like {"ts":"2026-10-16T09:41:07.382Z","tsMs":1792143667382,"timeSynced":true,"temperature":22.71}. The public area of the broker is open to everyone and meant for testing, so publish demo values only. Its limits are listed on the public MQTT test broker page.
How SNTP Works on the ESP32
configTime(0, 0, ...) starts the lwIP SNTP client with up to three servers and a zero offset, so the system clock runs in UTC. After the first response, the ESP32 keeps time with its internal timer and re-syncs periodically, by default once an hour. You can change that with sntp_set_sync_interval(ms); anything shorter than a few minutes is unnecessary for telemetry and adds load to public NTP pools.
getLocalTime(&tm, timeoutMs) waits until the year is plausible, which is a convenient blocking check at boot. The callback registered with sntp_set_time_sync_notification_cb fires on every successful sync, which is useful if you want to log drift or publish a "clock ok" status. The underlying system time API is described in the ESP-IDF system time guide.
Clock drift between syncs
The ESP32 main crystal is accurate enough that hourly syncs keep drift well below a second for ordinary telemetry. Deep sleep is different: by default the RTC timer runs from an internal RC oscillator that drifts much more. If your device sleeps for long periods, re-sync after each wake, or fit an external 32 kHz crystal.
Handling the Unsynced Window
The most common timestamp bug is publishing 1970-01-01T00:00:12Z right after boot. The sketch avoids it in three ways:
- It compares the clock with a known recent epoch (
VALID_EPOCH) before formatting. - When the clock is not valid, it sends
uptimeMsandtimeSynced:falseso the consumer can decide what to do. - It does not block forever if NTP is unreachable, so the device still reports.
A consumer can fall back to its own arrival time for unsynced messages, or drop them. Never let the device invent a time.
Showing Local Time on the Device
Sometimes the device itself needs local time, for example to switch a relay at 07:00 or show the clock on a small display. Keep the payload in UTC and convert only for those local uses. The C library on the ESP32 understands POSIX time zone strings, which include daylight saving rules:
// Central European Time with automatic summer time
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
struct tm local;
time_t now = time(nullptr);
localtime_r(&now, &local);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S%z", &local); // e.g. 2026-10-16T11:41:07+0200
gmtime_r in the main sketch ignores the TZ variable, so setting a time zone for local display does not change the UTC timestamps you publish. Look up the POSIX string for your region rather than computing offsets by hand; a fixed +02:00 offset is wrong for half the year in most of Europe.
Timestamps With Buffering and Tasks
Timestamps become essential once readings are queued. If you separate sampling and publishing as in the ESP32 FreeRTOS MQTT tutorial, store epochMs in the queued struct at capture time and format the JSON only in the publish task. A backlog flushed after an outage then lands at the correct position on every chart. For larger payloads, building JSON with a library is less error-prone than snprintf; see ArduinoJson with MQTT on the ESP32.
Event-style messages benefit too. A retained "last seen" value with a real timestamp tells any new subscriber exactly when something last happened; the PIR motion sensor project uses this pattern for its last-motion topic.
Troubleshooting Checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| Year 1970 in payloads | Published before first sync | Check VALID_EPOCH before formatting |
getLocalTime always times out |
UDP 123 blocked | Use an NTP server on your LAN or ask IT to allow it |
| Timestamps an hour off | Time zone applied twice | Keep device in UTC, convert only in dashboards |
Milliseconds always .000 |
Formatted from time() instead of gettimeofday() |
Use struct timeval |
| Wrong time after deep sleep | RTC drift during sleep | Re-sync after wake or add a 32 kHz crystal |
%llu prints garbage |
Wrong cast | Cast to unsigned long long |
Production Setup
When you move beyond testing, create a broker at app.connectmq.com, switch to WiFiClientSecure on port 8883 with the ISRG Root X1 certificate, and connect with YOUR_CLIENT_ID, YOUR_USERNAME and YOUR_PASSWORD from the portal. Note that synced time is also good hygiene for TLS: certificate validity checks and log correlation both rely on a correct clock. The ConnectMQ portal's live topic explorer then shows your timestamped messages as they arrive, which makes it easy to spot a device whose ts lags behind.
Get Started with ConnectMQ
Accurate device timestamps plus a reliable broker give you data you can trust. ConnectMQ is a managed MQTT broker with TLS, MQTT 3.1.1 and 5.0, and a free Serverless plan that needs no credit card. Create a broker, flash this sketch with your credentials, and explore the rest of the ESP32 IoT tutorial series for sensor projects that build on it.
Frequently Asked Questions
Does the MQTT broker add a timestamp to messages?
The MQTT protocol has no timestamp field, so subscribers only know arrival time. Put the capture time in the payload, or in an MQTT 5 user property, from a synced device clock.
Should I send ISO 8601 or epoch milliseconds?
ISO 8601 in UTC is readable and widely parsed; epoch milliseconds are compact and sort naturally. Many projects send both during development and keep one in production.
How often should the ESP32 resync with NTP?
The default hourly resync is fine for most telemetry. Resync after long deep sleep periods, because the sleep clock drifts more than the main crystal.
What if my device has no internet access for NTP?
Run an NTP server on a local gateway or router and point configTime at it. Without any time source, publish uptime and let the consumer timestamp on arrival.



