Combining ESP32, ArduinoJson and MQTT is the most common way to send structured sensor data from a microcontroller. In this tutorial you will build JSON telemetry with ArduinoJson 7, publish it efficiently with PubSubClient, parse JSON commands in the message callback and learn the buffer limits that cause messages to vanish without an error.
Why JSON Payloads for MQTT
MQTT does not care what is inside a payload; it moves bytes. That freedom means you have to choose a format. JSON wins for most ESP32 projects because every dashboard, Node-RED flow, Python script and database importer can read it without a custom decoder. A message like {"temp":22.4,"hum":51,"rssi":-63} is self-describing, easy to extend with new fields, and readable in any MQTT client while you debug.
The costs are size and parsing work, both negligible at typical sensor rates on an ESP32. If you are sending hundreds of messages per second or running on a tight battery budget, compact keys help more than switching formats.
ArduinoJson is the de facto library for this. Version 7 removed the need to pick a document capacity: JsonDocument grows as needed on the heap. All code below targets ArduinoJson 7; if you are on version 6, replace JsonDocument with StaticJsonDocument<N> or DynamicJsonDocument. The official ArduinoJson documentation covers migration details.
What You Need
- An ESP32 board and the Arduino IDE or PlatformIO.
- Libraries: PubSubClient by Nick O'Leary and ArduinoJson by Benoit Blanchon (7.x).
mosquitto_pubandmosquitto_subon your computer to send commands and watch output.
The examples use the public area of broker.connectmq.com on port 1883 with the topic prefix connectmq/demo/a8e2d1. Change that prefix to your own random string, and remember that public topics are readable by anyone.
| Topic | Direction | Payload |
|---|---|---|
connectmq/demo/a8e2d1/telemetry |
ESP32 publishes | {"device":"...","temp":22.4,"hum":51,"uptime":120} |
connectmq/demo/a8e2d1/cmd |
ESP32 subscribes | {"action":"set_interval","value":10} |
connectmq/demo/a8e2d1/ack |
ESP32 publishes | {"ok":true,"action":"set_interval"} |
Complete ESP32 ArduinoJson MQTT Sketch
The sketch publishes telemetry on a configurable interval and accepts JSON commands to change that interval or toggle an LED.
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* TOPIC_TELEMETRY = "connectmq/demo/a8e2d1/telemetry";
const char* TOPIC_CMD = "connectmq/demo/a8e2d1/cmd";
const char* TOPIC_ACK = "connectmq/demo/a8e2d1/ack";
const int LED_PIN = 2;
WiFiClient net;
PubSubClient mqtt(net);
String deviceId;
unsigned long intervalMs = 5000;
unsigned long lastPublish = 0;
void publishAck(const char* action, bool ok, const char* error = nullptr) {
JsonDocument doc;
doc["ok"] = ok;
doc["action"] = action;
if (error) doc["error"] = error;
char buf[128];
size_t n = serializeJson(doc, buf, sizeof(buf));
mqtt.publish(TOPIC_ACK, (const uint8_t*)buf, n, false);
}
void onMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, payload, length);
if (err) {
publishAck("unknown", false, err.c_str());
return;
}
const char* action = doc["action"] | "";
if (strcmp(action, "set_interval") == 0) {
int seconds = doc["value"] | 0;
if (seconds < 1 || seconds > 3600) {
publishAck(action, false, "value out of range");
return;
}
intervalMs = seconds * 1000UL;
publishAck(action, true);
} else if (strcmp(action, "led") == 0) {
bool on = doc["value"] | false;
digitalWrite(LED_PIN, on ? HIGH : LOW);
publishAck(action, true);
} else {
publishAck(action, false, "unknown action");
}
}
void publishTelemetry() {
JsonDocument doc;
doc["device"] = deviceId;
doc["temp"] = serialized(String(20.0 + random(0, 50) / 10.0, 1));
doc["hum"] = 45 + random(0, 15);
doc["uptime"] = millis() / 1000;
JsonObject network = doc["net"].to<JsonObject>();
network["rssi"] = WiFi.RSSI();
network["ip"] = WiFi.localIP().toString();
size_t len = measureJson(doc);
mqtt.beginPublish(TOPIC_TELEMETRY, len, false);
serializeJson(doc, mqtt); // stream straight into the MQTT packet
mqtt.endPublish();
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(deviceId.c_str())) {
mqtt.subscribe(TOPIC_CMD);
} else {
Serial.printf("MQTT failed, state=%d\n", mqtt.state());
delay(3000);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
deviceId = "esp32-json-" + String((uint32_t)ESP.getEfuseMac(), HEX);
mqtt.setServer("broker.connectmq.com", 1883);
mqtt.setBufferSize(1024); // default is 256 bytes, too small for most JSON
mqtt.setCallback(onMessage);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish >= intervalMs) {
lastPublish = millis();
publishTelemetry();
}
}
Watch the output and send commands from your computer:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-a8e2d1 -t 'connectmq/demo/a8e2d1/#' -v
mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-a8e2d1-pub \
-t connectmq/demo/a8e2d1/cmd -m '{"action":"set_interval","value":10}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-a8e2d1-pub \
-t connectmq/demo/a8e2d1/cmd -m '{"action":"led","value":true}'
Serializing: Three Ways to Get JSON Into a Publish
Into a Fixed Buffer
serializeJson(doc, buf, sizeof(buf)) writes into a char array and returns the number of bytes written. This is the simplest and works well for small, predictable messages like the acknowledgement above. Pass the returned length to publish(), using the overload that takes a byte pointer and a length, rather than relying on strlen().
Streaming Into the MQTT Client
PubSubClient inherits from Arduino's Print class, so ArduinoJson can write directly into it. measureJson(doc) gives the exact size first, beginPublish() writes the MQTT header with that size, serializeJson(doc, mqtt) streams the body and endPublish() finishes the packet. No intermediate buffer is needed, which is the most memory-efficient option for larger documents. You still need setBufferSize() for regular publish() calls and for incoming messages, which are always read into the buffer.
Into a String
serializeJson(doc, someString) is convenient but allocates on the heap for every message. On a device that runs for weeks, repeated String allocations can fragment memory. Prefer the first two options in long-running firmware.
Controlling Float Precision
ArduinoJson prints floats with enough digits to round-trip, which can give values like 22.399999. Wrapping a formatted string in serialized() inserts it verbatim, as in the telemetry function above. Alternatively, round the number before assigning it: doc["temp"] = round(t * 10) / 10.0;.
Parsing JSON Commands Safely
The MQTT callback receives a byte pointer and a length, and the payload is not null-terminated. Always pass the length: deserializeJson(doc, payload, length). Calling it with only (char*)payload reads past the end of the message.
Three more habits make parsing robust:
- Check the error.
DeserializationErrortells you whether the input was empty, incomplete, invalid or too deep. Report it back on an ack topic, as the sketch does, so the sender knows the command failed. - Use defaults with the
|operator.doc["value"] | 0returns 0 if the field is missing or has the wrong type, so a malformed command cannot crash the device or leave a variable uninitialised. - Validate ranges. JSON parsing succeeding does not mean the values make sense. Reject intervals of zero seconds or brightness values of 5000 explicitly.
If you only need a few fields from a large incoming document, use a filter so ArduinoJson ignores the rest:
JsonDocument filter;
filter["action"] = true;
filter["value"] = true;
JsonDocument doc;
deserializeJson(doc, payload, length, DeserializationOption::Filter(filter));
Buffer and Memory Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
publish() returns false, nothing arrives |
Packet larger than PubSubClient's 256-byte default buffer | mqtt.setBufferSize(1024) before connect() |
| Incoming JSON never reaches the callback | Incoming message larger than the buffer | Same: raise the buffer to fit the largest expected command |
IncompleteInput errors |
Parsing with strlen() on a non-terminated payload |
Pass length to deserializeJson() |
| Random reboots after days | Heap fragmentation from String-based serialisation | Serialize into fixed buffers or stream into the client |
| Values print with long decimals | Default float formatting | serialized() or rounding before assignment |
Keep the broker limits in mind as well. The public area of broker.connectmq.com accepts up to 64 KB per message, and the free ConnectMQ Serverless plan up to 256 KB, which is far beyond what an ESP32 sketch should send in one message. If your JSON is getting large, split it into topics such as .../telemetry and .../diagnostics. The PubSubClient buffer size guide explains the buffer arithmetic in detail.
Designing the JSON Schema
A few conventions save trouble once more than one device and more than one consumer are involved:
- Include a device ID or put it in the topic, not both inconsistently.
- Use units in key names (
temp_c,power_w) or document them once; do not mix units across devices. - Add a timestamp only if the device has synced time. Otherwise let the consumer stamp the arrival time; see adding NTP timestamps to ESP32 MQTT messages.
- Keep commands and telemetry on separate topics so a device never parses its own messages.
- Version the payload with a field like
"v":1if the format might change.
Using It With Real Sensors
Replace the random values with actual readings and the rest of the sketch stays the same. The ESP32 DHT22 MQTT tutorial shows the sensor side, and the ESP32 IoT tutorial assembles a full device project around the same JSON pattern.
Send Your JSON Telemetry to ConnectMQ
Testing against the free public MQTT broker is the fastest way to iterate on a payload format. When the data matters, move to a private broker: ConnectMQ is a fully managed MQTT service whose portal shows your JSON messages live in the topic explorer and lets you build threshold alert rules on numeric fields. Sign up for the free Serverless plan at app.connectmq.com, no credit card required, then switch the sketch to WiFiClientSecure on port 8883 with YOUR_CLIENT_ID, YOUR_USERNAME and YOUR_PASSWORD.
Frequently Asked Questions
Why does PubSubClient fail to publish my JSON?
The default buffer is 256 bytes, including the topic and header. Call mqtt.setBufferSize() with a larger value before connecting, and check the return value of publish().
Should I use ArduinoJson 6 or 7?
Use version 7 for new projects. It replaces fixed-capacity documents with a single JsonDocument that sizes itself, which removes a common source of truncated output.
Is JSON too heavy for an ESP32 over MQTT?
No. At typical sensor rates the parsing and serialisation cost is tiny. Keep keys short and payloads focused if you are on battery or sending many messages per second.
How do I parse the payload when it is not null-terminated?
Pass the length to the parser: deserializeJson(doc, payload, length). ArduinoJson then reads exactly the bytes of the message.



