A DHT22 read produces temperature and relative humidity from the same sampling event. Publishing them on separate MQTT topics works, but it also creates two independent message boundaries. A subscriber can receive the new temperature before the matching humidity value arrives.

Putting both measurements in one payload preserves the relationship explicitly:

{"temperature":25.34,"humidity":60.21}

MQTT does not require JSON. The useful property here is that both measurements travel in one publication and can be treated as one sensor-state snapshot.

One topic for one sensor state

With separate topics, the broker sees two publications:

device/temp -> 25.34
device/hum  -> 60.21

A combined state topic changes that boundary:

device/sensor -> {"temperature":25.34,"humidity":60.21}

This does not make the wider application transactional. It only guarantees that these fields are carried in the same MQTT payload. For values sampled together, that is often the cleaner contract for dashboards, storage consumers, and automation rules.

A fixed buffer is enough for two numeric fields:

char payload[64];

snprintf(
    payload,
    sizeof(payload),
    "{\"temperature\":%.2f,\"humidity\":%.2f}",
    temperature,
    humidity
);

The values remain JSON numbers rather than quoted strings, so downstream consumers do not need an extra string-to-number conversion.

Retained telemetry is different from a command

Publishing the sensor state with the retain flag lets a later subscriber receive the broker’s stored value without waiting for the next sample:

client.publish(topic_sensor, payload, true);

That behavior fits a state topic such as:

cccb20c92d2b32/sensor

The LED command has different semantics:

cccb20c92d2b32/lamp

The ESP32 subscribes to that topic and reacts to ON or OFF. Retaining commands should be a deliberate choice: a retained command can be delivered again after a device reconnects. That can model desired state, but it is usually wrong for a one-shot action.

The callback payload needs an explicit string boundary

PubSubClient provides an incoming payload as bytes plus a length. Code should not assume the payload already ends with a null byte.

char message[length + 1];
memcpy(message, payload, length);
message[length] = '\0';

if (strcmp(topic, topic_led) == 0) {
  if (strcmp(message, "ON") == 0) {
    digitalWrite(LED_PIN, HIGH);
  } else if (strcmp(message, "OFF") == 0) {
    digitalWrite(LED_PIN, LOW);
  }
}

The copy makes the boundary explicit before strcmp() treats the data as a C string.

A manual client ID is a connection identity

A fixed client ID is valid when one firmware instance represents one device:

const char* mqtt_client_id = "cccb20c92d2b32";

It must not be reused by another simultaneously connected client on the same broker. It is also not a credential. A long or hard-to-guess client ID does not provide topic authorization.

When testing with MQTTX or another ESP32, give the test client a different client ID while subscribing to the same topics.

Connection maintenance belongs in the main loop

PubSubClient needs client.loop() to run regularly while connected. Long blocking delays can interfere with that work. PubSubClient defaults to a 15-second keepalive and exposes setKeepAlive() when a different interval is appropriate.

Sensor timing can use millis() instead of delay(5000), leaving the processor available for MQTT between DHT22 reads.

Reconnect attempts also should not execute on every CPU loop. A five-second retry interval is simple and keeps a broker outage from turning into a tight connection loop:

if (!client.connected() &&
    millis() - lastMqttAttempt >= mqttRetryInterval) {
  lastMqttAttempt = millis();

  if (client.connect(mqtt_client_id)) {
    client.subscribe(topic_led);
  } else {
    Serial.println(client.state());
  }
}

The state code is useful during diagnosis. A reconnect loop can originate below MQTT in Wi-Fi or TCP, at the broker, or in MQTT connection handling. A unique client ID rules out one common cause, not every cause.

Complete ESP32 sketch

The following sketch publishes one retained JSON state every five seconds and receives LED commands through a separate topic.

#include <WiFi.h>
#include <PubSubClient.h>
#include "DHT.h"

const char* ssid = "YOUR_WIFI";
const char* password = "YOUR_PASSWORD";
const char* mqtt_server = "broker.emqx.io";
const char* mqtt_client_id = "cccb20c92d2b32";

const char* topic_sensor = "cccb20c92d2b32/sensor";
const char* topic_led = "cccb20c92d2b32/lamp";

#define DHTPIN 4
#define DHTTYPE DHT22
#define LED_PIN 5

WiFiClient espClient;
PubSubClient client(espClient);
DHT dht(DHTPIN, DHTTYPE);

unsigned long lastPublish = 0;
unsigned long lastMqttAttempt = 0;
const unsigned long publishInterval = 5000;
const unsigned long mqttRetryInterval = 5000;

void callback(char* topic, byte* payload, unsigned int length) {
  char message[length + 1];
  memcpy(message, payload, length);
  message[length] = '\0';

  if (strcmp(topic, topic_led) != 0) return;

  if (strcmp(message, "ON") == 0) {
    digitalWrite(LED_PIN, HIGH);
  } else if (strcmp(message, "OFF") == 0) {
    digitalWrite(LED_PIN, LOW);
  }
}

void setup_wifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
  }
}

void connect_mqtt() {
  if (client.connected()) return;

  unsigned long now = millis();
  if (now - lastMqttAttempt < mqttRetryInterval) return;
  lastMqttAttempt = now;

  if (client.connect(mqtt_client_id)) {
    Serial.println("MQTT connected");
    client.subscribe(topic_led);
  } else {
    Serial.print("MQTT failed, state=");
    Serial.println(client.state());
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  dht.begin();
  setup_wifi();

  client.setServer(mqtt_server, 1883);
  client.setCallback(callback);
  client.setKeepAlive(60);
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    setup_wifi();
  }

  connect_mqtt();

  if (client.connected()) {
    client.loop();
  }

  unsigned long now = millis();
  if (now - lastPublish < publishInterval) return;
  lastPublish = now;

  float temperature = dht.readTemperature();
  float humidity = dht.readHumidity();

  if (isnan(temperature) || isnan(humidity)) {
    Serial.println("DHT read failed");
    return;
  }

  char payload[64];
  snprintf(
      payload,
      sizeof(payload),
      "{\"temperature\":%.2f,\"humidity\":%.2f}",
      temperature,
      humidity
  );

  if (!client.connected()) {
    Serial.println("MQTT offline");
    return;
  }

  bool published = client.publish(topic_sensor, payload, true);

  Serial.print(published ? "Publish OK -> " : "Publish FAILED -> ");
  Serial.println(payload);
}

EMQX exposes broker.emqx.io on TCP port 1883 as a public MQTT endpoint for testing. Port 1883 is unencrypted, and the public broker is shared. The sketch therefore uses placeholder Wi-Fi credentials and should not carry private telemetry or trusted actuator commands in this form.

For a deployed device, authentication, access control, and TLS are separate requirements from the JSON message format.

The important boundary is the publication

Combining temperature and humidity does more than reduce the topic count. It defines which values belong to one sensor state. Retain then applies to that complete state, and subscribers parse one payload instead of coordinating two independently arriving messages.

The rest of the firmware should preserve that clarity: commands stay on their own topic, callback bytes get an explicit string boundary, client.loop() keeps running, and reconnect attempts are rate-limited. JSON is only the representation; the MQTT message boundary is the part that gives the state its meaning.