ESP32 and Arduino

ESP32 FreeRTOS MQTT: Separate Sensor and Publishing Tasks with Queues

Split sensor sampling and MQTT publishing into two FreeRTOS tasks on the ESP32, connect them with a queue and keep readings flowing even while Wi-Fi or the broker reconnects.

Diagram of two ESP32 FreeRTOS tasks passing sensor readings through a queue to an MQTT publisher in an esp32 freertos mqtt setup
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 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_sub on 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

  1. Guru Meditation "Stack canary watchpoint triggered". The task stack is too small. snprintf with floats and TLS both need headroom. Print uxTaskGetStackHighWaterMark(nullptr) inside each task during development; it returns the minimum free bytes ever seen. Keep at least 512 bytes spare.
  2. 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, xQueueReceive with a timeout, or a blocking socket call.
  3. Garbled serial output. Two tasks printing at once interleave characters. Either print from one task only or guard Serial with a mutex created by xSemaphoreCreateMutex().
  4. Publishing from the sensor task. It will seem to work, then fail randomly under load. Keep the MQTT client in one task.
  5. 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.
  6. 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.

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.