An ESP32 FreeRTOS MQTT design puts sensor sampling and network publishing in separate tasks, so a slow Wi-Fi reconnect never delays a reading. In this tutorial you will build a sketch with a sensor task, an MQTT task and a queue between them, test it against the free public area of broker.connectmq.com, and then switch it to TLS with your own credentials.
Why Use FreeRTOS Tasks for MQTT on the ESP32
Most beginner sketches do everything inside loop(): read the sensor, check Wi-Fi, call client.loop(), publish, then delay(). That works until the network misbehaves. A connect() call to an unreachable broker can block for several seconds, and during that time nothing is sampled. If you are counting pulses, averaging an analog signal or watching a threshold, those gaps matter.
The ESP32 Arduino core already runs on FreeRTOS. loop() is itself just a task pinned to core 1. Nothing stops you from creating more tasks, and once you do, the timing of one job no longer depends on the other:
- The sensor task wakes on a fixed period with
vTaskDelayUntil(), reads the hardware and drops a small struct into a queue. It never touches the network. - The MQTT task owns Wi-Fi and the MQTT client. It drains the queue, publishes, services keep-alives and reconnects when needed.
- The queue is the only shared object. FreeRTOS queues copy data and are safe to use between tasks, so you need no mutex for the readings.
There is one rule that makes this design robust: only one task ever calls the MQTT client. PubSubClient is not thread-safe, and calling publish() from two tasks at once corrupts its internal buffer. Funnel everything through the queue instead.
What You Need
- Any ESP32 board with two cores (ESP32 DevKit, ESP32-S3). Single-core chips such as the ESP32-C3 or ESP32-C6 also work if you pin both tasks to core 0.
- Arduino IDE 2.x or PlatformIO with the esp32 core 3.x.
- The PubSubClient library by Nick O'Leary.
- Optional: any sensor. The sketch simulates temperature and humidity so it runs on a bare board.
mosquitto_subon your computer for testing.
Task Layout and Priorities
| Task | Core | Priority | Stack | Job |
|---|---|---|---|---|
sensorTask |
1 | 3 | 3072 bytes | Sample every 1 s, push to queue |
mqttTask |
0 | 2 | 6144 bytes | Wi-Fi, MQTT connect, publish, keep-alive |
Arduino loop() |
1 | 1 | default | Deleted, not needed |
The sensor task gets the higher priority because it is short and time-critical; it runs, queues one item and sleeps. The MQTT task runs on core 0 alongside the Wi-Fi stack, which also lives there, and does the slow work. Stack sizes in the ESP32 Arduino core are given in bytes, not words as in vanilla FreeRTOS.
The Complete ESP32 FreeRTOS MQTT Sketch
Replace the Wi-Fi credentials and the random part of the topic prefix, then upload.
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- configuration ----------
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_PREFIX = "connectmq/demo/k3v9q1"; // make this unique
// ---------- shared objects ----------
struct Reading {
uint32_t seq;
uint32_t uptimeMs;
float temperature;
float humidity;
};
QueueHandle_t readingQueue;
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
// ---------- sensor task ----------
void sensorTask(void* param) {
const TickType_t period = pdMS_TO_TICKS(1000);
TickType_t lastWake = xTaskGetTickCount();
uint32_t seq = 0;
for (;;) {
Reading r;
r.seq = seq++;
r.uptimeMs = millis();
// Replace with a real sensor read (DHT22, BME280, ADC...)
r.temperature = 21.0f + (esp_random() % 400) / 100.0f;
r.humidity = 45.0f + (esp_random() % 1000) / 100.0f;
// Do not block: if the queue is full, drop the oldest item and retry
if (xQueueSend(readingQueue, &r, 0) != pdTRUE) {
Reading discarded;
xQueueReceive(readingQueue, &discarded, 0);
xQueueSend(readingQueue, &r, 0);
}
vTaskDelayUntil(&lastWake, period);
}
}
// ---------- MQTT helpers (only called from mqttTask) ----------
bool ensureWifi() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) {
vTaskDelay(pdMS_TO_TICKS(250));
}
return WiFi.status() == WL_CONNECTED;
}
bool ensureMqtt() {
if (mqtt.connected()) return true;
String willTopic = String(TOPIC_PREFIX) + "/status";
bool ok = mqtt.connect(clientId.c_str(), willTopic.c_str(), 1, true, "offline");
if (ok) {
mqtt.publish(willTopic.c_str(), "online", true);
}
return ok;
}
// ---------- MQTT task ----------
void mqttTask(void* param) {
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
String topic = String(TOPIC_PREFIX) + "/telemetry";
uint32_t backoffMs = 1000;
for (;;) {
if (!ensureWifi() || !ensureMqtt()) {
vTaskDelay(pdMS_TO_TICKS(backoffMs));
backoffMs = min<uint32_t>(backoffMs * 2, 30000);
continue;
}
backoffMs = 1000;
mqtt.loop();
Reading r;
// Wait briefly for data so the task yields CPU when idle
if (xQueueReceive(readingQueue, &r, pdMS_TO_TICKS(50)) == pdTRUE) {
char payload[160];
snprintf(payload, sizeof(payload),
"{\"seq\":%lu,\"uptimeMs\":%lu,\"temperature\":%.2f,\"humidity\":%.2f,\"queued\":%u}",
(unsigned long)r.seq, (unsigned long)r.uptimeMs,
r.temperature, r.humidity,
(unsigned)uxQueueMessagesWaiting(readingQueue));
if (!mqtt.publish(topic.c_str(), payload)) {
// Put it back at the front and retry after reconnect
xQueueSendToFront(readingQueue, &r, 0);
}
}
}
}
void setup() {
Serial.begin(115200);
clientId = "esp32-rtos-" + String((uint32_t)ESP.getEfuseMac(), HEX);
readingQueue = xQueueCreate(60, sizeof(Reading)); // one minute of buffer
if (readingQueue == nullptr) {
Serial.println("Queue allocation failed");
while (true) delay(1000);
}
xTaskCreatePinnedToCore(sensorTask, "sensor", 3072, nullptr, 3, nullptr, 1);
xTaskCreatePinnedToCore(mqttTask, "mqtt", 6144, nullptr, 2, nullptr, 0);
}
void loop() {
vTaskDelete(nullptr); // the Arduino loop task is not needed
}
Watch the output from your computer:
mosquitto_sub -h broker.connectmq.com -p 1883 -i sub-k3v9q1 -t "connectmq/demo/k3v9q1/#" -v
You should see a status message of online and one telemetry message per second. Now unplug your router for twenty seconds. The queued field grows while the board is offline, and when the connection returns the MQTT task drains the backlog in order. That is the whole point of the design: readings were taken on schedule even though publishing stalled.
The public area of the broker is readable by anyone, so keep demo data generic. It also limits each client to 10 messages per second, which a backlog drain can hit. If you buffer many readings, add a short vTaskDelay(pdMS_TO_TICKS(120)) after each publish while uxQueueMessagesWaiting() is high. The free public MQTT broker guide lists the other limits.
How the Pieces Work
The queue as a buffer
xQueueCreate(60, sizeof(Reading)) allocates 60 fixed-size slots on the heap. Each xQueueSend copies the 16-byte struct, so the sensor task can reuse its local variable immediately. Sizing the queue is a trade-off between RAM and how long an outage you want to survive. Sixty slots of 16 bytes is under 1 KB; even a few thousand slots fit easily on a classic ESP32, but remember that the data is lost on reboot.
When the queue is full, the sketch drops the oldest reading rather than the newest. For telemetry the latest value is usually the most useful. If every sample matters, block for a few ticks instead, or aggregate readings (minimum, maximum, average) before queueing.
vTaskDelayUntil versus delay
vTaskDelayUntil() wakes the task at a fixed rate measured from the previous wake time, so the period does not drift by however long the sensor read took. A plain delay(1000) after a 30 ms read gives you a 1.03 s period that slowly slides. Both functions yield the CPU, unlike a busy loop with millis().
Reconnect with backoff
The MQTT task retries with exponential backoff capped at 30 seconds. Hammering the broker with immediate retries wastes power and, on a managed service, can trip flood protection or repeated-failure IP blocking. The ESP32 MQTT last will guide explains why the retained offline will message is registered at connect time, so dashboards notice a dead device.
Pitfalls and Troubleshooting
- Guru Meditation "Stack canary watchpoint triggered". The task stack is too small.
snprintfwith floats and TLS both need headroom. PrintuxTaskGetStackHighWaterMark(nullptr)inside each task during development; it returns the minimum free bytes ever seen. Keep at least 512 bytes spare. - Task watchdog resets. A task that loops without ever blocking starves the idle task on its core. Every loop iteration must call something that yields:
vTaskDelay,xQueueReceivewith a timeout, or a blocking socket call. - Garbled serial output. Two tasks printing at once interleave characters. Either print from one task only or guard
Serialwith a mutex created byxSemaphoreCreateMutex(). - Publishing from the sensor task. It will seem to work, then fail randomly under load. Keep the MQTT client in one task.
- Callbacks run in the MQTT task. If you subscribe to commands, the PubSubClient callback runs inside
mqtt.loop(). Do not do slow work there; push the command into a second queue for another task to handle. - Large JSON truncated. PubSubClient's default buffer is 256 bytes including the topic. The sketch raises it with
setBufferSize(512).
Moving to TLS and Your Own Broker
The public area is ideal for testing, but production devices should authenticate and encrypt. Create a free account at app.connectmq.com, create a broker, and copy the generated username, password and client ID. Then change only the networking part of the MQTT task:
#include <WiFiClientSecure.h>
// ISRG Root X1, the Let's Encrypt root. Paste the PEM published by Let's Encrypt.
static const char ROOT_CA[] = R"PEM(
-----BEGIN CERTIFICATE-----
...paste ISRG Root X1 here...
-----END CERTIFICATE-----
)PEM";
WiFiClientSecure net;
PubSubClient mqtt(net);
// in mqttTask, before the loop:
net.setCACert(ROOT_CA);
mqtt.setServer("broker.connectmq.com", 8883);
// in ensureMqtt():
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
willTopic.c_str(), 1, true, "offline");
Raise the MQTT task stack to about 8192 bytes, because the TLS handshake uses noticeably more stack and heap. On ConnectMQ the client ID is bound to the issued credentials, so use exactly the one the portal gives you. Once data flows, open the live topic explorer in the portal to confirm messages arrive and set a threshold alert rule on the temperature field.
FreeRTOS on the ESP32 is documented in detail in the ESP-IDF FreeRTOS reference, including the SMP differences from upstream FreeRTOS. For a wider view of device projects, the ESP32 IoT tutorial collects the rest of this series, and adding NTP timestamps to the Reading struct is a natural next step so buffered samples carry their real capture time.
Get Started with ConnectMQ
A task-based sketch is only half of a reliable system; the other half is a broker that stays up while your devices reconnect. ConnectMQ is a fully managed MQTT service with MQTT 3.1.1 and 5.0, TLS on 8883 and a portal that shows your devices and topics live. Start on the free Serverless plan with no credit card, then move to a paid or dedicated managed MQTT broker plan when your fleet grows past 25 connections. Once the firmware is solid, the same pattern works for real sensors such as the ESP32 DHT22 project.
Frequently Asked Questions
Can I call PubSubClient from more than one FreeRTOS task?
Not safely. PubSubClient has a single shared buffer and no locking, so concurrent calls corrupt packets. Keep it in one task and send data to that task through a queue.
Which core should the MQTT task run on?
Core 0 is a good default because the Wi-Fi and lwIP tasks already run there. On single-core chips like the ESP32-C3 or ESP32-C6, pin every task to core 0 or use xTaskCreate without pinning.
How large should the reading queue be?
Size it for the longest outage you want to bridge. Multiply the sample rate by the outage length and the struct size, and keep the total well below the free heap reported by ESP.getFreeHeap().
Does this work with AsyncMqttClient?
Yes, but AsyncMqttClient already runs its own network task and callbacks, so you only need the sensor task and a queue. Publish from the task that drains the queue, not from inside the library callbacks.



