ESP32 FreeRTOS Tutorial: Tasks, Queues, Semaphores & Dual-Core Multitasking
If your ESP32 sketch has turned into one giant loop() full of delay() calls, sensor polling, and MQTT publishing all fighting for the same execution slot, you've already hit the wall that FreeRTOS is built to solve. FreeRTOS isn't an add-on library — it's the operating system quietly running underneath every Arduino sketch and ESP-IDF project on the ESP32. Once you start using it deliberately instead of by accident, your code gets faster, more responsive, and a lot less fragile.
This guide covers what you actually need to build reliable multitasking firmware: creating and pinning tasks to the ESP32's two cores, passing data safely between tasks with queues, protecting shared resources with semaphores and mutexes, and — just as important — the mistakes that cause random reboots, stack overflows, and watchdog timeouts in real projects.
Quick Answer: What Is FreeRTOS on the ESP32?
FreeRTOS is the real-time operating system built into the ESP32 that lets it run multiple independent "tasks" at once instead of a single blocking loop. On Arduino, your setup() and loop() already run inside a hidden FreeRTOS task called loopTask. The ESP32's dual-core Xtensa design means FreeRTOS can also assign tasks to a specific CPU core — Core 0 (PRO_CPU), which usually handles Wi-Fi and Bluetooth, or Core 1 (APP_CPU), which is free for your application logic. You create tasks with xTaskCreatePinnedToCore(), and let them communicate through queues, semaphores, and mutexes instead of shared global variables.
Why Bother With FreeRTOS Instead of Just Using loop()?
A plain loop() works fine until you need to do more than one time-sensitive thing at once. Say you're reading a sensor every 100 ms, publishing over MQTT every 5 seconds (see our <a href="https://www.xloge.site/2026/07/esp32-mqtt-tutorial-complete-guide-to.html">ESP32 MQTT tutorial</a> if you haven't built that part yet), and updating a display. Every delay() call in that loop blocks everything else. A slow Wi-Fi reconnect stalls your sensor reads. A blocking display update makes your MQTT client miss its keep-alive window.
FreeRTOS tasks solve this because each one has its own stack and runs independently. A task blocked waiting for Wi-Fi doesn't stop your sensor-reading task from running on the other core. This is also why the ESP32 can run Wi-Fi, Bluetooth, sensor logic, and networking simultaneously without you writing a single state machine by hand.
Creating Your First Task
Every FreeRTOS task on the ESP32 is created with xTaskCreatePinnedToCore(). It takes the function to run, a name for debugging, a stack size in bytes, an optional parameter, a priority, a handle you can use to reference the task later, and the core to pin it to.
void sensorTask(void *parameter) {
for (;;) {
// read a sensor here
Serial.println("Reading sensor...");
vTaskDelay(pdMS_TO_TICKS(500)); // non-blocking delay, frees the CPU
}
}
void setup() {
Serial.begin(115200);
xTaskCreatePinnedToCore(
sensorTask, // task function
"SensorTask", // name (for debugging)
4096, // stack size in bytes
NULL, // parameter to pass in
1, // priority (0 = lowest)
NULL, // task handle (optional)
1 // core ID: 1 = APP_CPU
);
}
void loop() {
// loop() itself runs as a task — keep it light or empty
}A few details that matter here and that a lot of tutorials skim over:
- Never use
delay()inside a task. It blocks the whole task, including FreeRTOS's ability to switch to something else. UsevTaskDelay(pdMS_TO_TICKS(ms))instead — it puts the task to sleep and lets other tasks run. - Core 0 is where Wi-Fi and Bluetooth live internally. Espressif's own SMP documentation notes that on the ESP32, Core 0 is the PRO_CPU and Core 1 is the APP_CPU, and pinning application tasks to Core 1 keeps them from competing with the radio stack for CPU time. If you pass
tskNO_AFFINITYinstead of0or1, the scheduler is free to run the task on either core. - Task functions never return. They're written as an infinite
for(;;)loop. If a task needs to end, it callsvTaskDelete(NULL)on itself.
Sizing the Stack (Without Guessing)
The 4096 in the example above is the stack size in bytes, and getting it wrong is one of the most common sources of crashes. Too small, and you get a stack overflow that corrupts FreeRTOS's internal task-management structures — often showing up as a random crash somewhere completely unrelated to the actual overflow. Too large, and you waste RAM you don't have much of to begin with.
Instead of guessing, check the actual usage while your firmware runs:
Serial.println(uxTaskGetStackHighWaterMark(NULL)); // bytes of stack still unusedCall this from inside the task itself. A result close to zero means you're cutting it dangerously close and should raise the stack size; a result of several thousand bytes usually means you can safely shrink it. As a starting point, 2048–4096 bytes is reasonable for simple tasks, and tasks that call into Wi-Fi, TLS, or JSON parsing libraries typically need 4096–8192 bytes because those libraries put large buffers on the stack.
Passing Data Between Tasks With Queues
Once you have more than one task, you need a safe way to move data between them — for example, a sensor task collecting readings and a networking task publishing them. Sharing a global variable directly is a common shortcut that leads to race conditions, where one task reads a value mid-write from another. FreeRTOS queues solve this cleanly: they're thread-safe, fixed-size buffers that one task writes to and another reads from.
QueueHandle_t sensorQueue;
void sensorTask(void *parameter) {
float reading;
for (;;) {
reading = 23.5; // replace with a real sensor read
xQueueSend(sensorQueue, &reading, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void networkTask(void *parameter) {
float value;
for (;;) {
if (xQueueReceive(sensorQueue, &value, portMAX_DELAY) == pdTRUE) {
Serial.printf("Publishing reading: %.2f\n", value);
// publish to MQTT here
}
}
}
void setup() {
Serial.begin(115200);
sensorQueue = xQueueCreate(10, sizeof(float)); // holds up to 10 float values
xTaskCreatePinnedToCore(sensorTask, "Sensor", 2048, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(networkTask, "Network", 4096, NULL, 1, NULL, 0);
}xQueueReceive with portMAX_DELAY blocks the network task efficiently — it uses zero CPU while waiting, and wakes up the instant new data arrives. That's the core advantage of queues over polling a shared variable in a loop: no wasted CPU cycles, and no race conditions.
Semaphores and Mutexes: Protecting Shared Resources
Queues move data. Semaphores and mutexes protect access instead. They're easy to confuse, so here's the practical distinction:
| Mechanism | Purpose | Typical use case |
|---|---|---|
| Binary semaphore | Signals that an event happened | An ISR signals a task that new data is ready |
| Counting semaphore | Tracks how many of a resource are available | Limiting concurrent access to a pool of N buffers |
| Mutex | Ensures only one task accesses a shared resource at a time, with priority inheritance | Protecting a shared I2C bus, SPI display, or SD card |
| Task notification | A lightweight, built-in signal to a specific task | Fast, low-overhead alternative to a binary semaphore |
A mutex is the right tool when two tasks both need to touch the same peripheral — say, an I2C sensor and an OLED display on the same bus:
SemaphoreHandle_t i2cMutex;
void readSensorTask(void *parameter) {
for (;;) {
if (xSemaphoreTake(i2cMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// safe to use the I2C bus here
xSemaphoreGive(i2cMutex);
}
vTaskDelay(pdMS_TO_TICKS(200));
}
}
void setup() {
i2cMutex = xSemaphoreCreateMutex();
// create tasks that share i2cMutex here
}The reason to reach for a mutex specifically (rather than a binary semaphore) when protecting a resource is priority inheritance: if a low-priority task is holding the mutex and a high-priority task is waiting on it, FreeRTOS temporarily boosts the low-priority task so it finishes and releases the resource faster. Binary semaphores don't do this, which is why they're better suited to event signaling than resource protection.
Software Timers and Task Notifications
Two tools worth knowing that most ESP32 tutorials skip entirely:
Software timers (xTimerCreate) let you run a callback at a fixed interval without dedicating a whole task to it — useful for things like a periodic heartbeat or a debounce window.
Task notifications (xTaskNotifyGive / ulTaskNotifyTake) are a lighter-weight alternative to a binary semaphore when you're signaling one specific, known task. They avoid the memory overhead of creating a separate semaphore object and are noticeably faster, which matters in interrupt handlers where every microsecond counts.
void IRAM_ATTR buttonISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(buttonTaskHandle, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void buttonTask(void *parameter) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // blocks until notified
Serial.println("Button pressed");
}
}A Practical Example: Sensor + MQTT Without Blocking Either
Here's how the pieces fit together in something close to a real project — one task samples a sensor on Core 1, a queue hands the readings off, and a networking task on Core 0 publishes them over MQTT without ever blocking the sensor loop, even if the network is slow or briefly disconnected.
QueueHandle_t sensorQueue;
void sensorTask(void *parameter) {
float reading;
for (;;) {
reading = readTemperature(); // your sensor code
xQueueSend(sensorQueue, &reading, pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void mqttTask(void *parameter) {
float value;
for (;;) {
if (xQueueReceive(sensorQueue, &value, portMAX_DELAY) == pdTRUE) {
// publish 'value' over MQTT — see the MQTT tutorial for the client setup
}
}
}
void setup() {
sensorQueue = xQueueCreate(20, sizeof(float));
xTaskCreatePinnedToCore(sensorTask, "Sensor", 2048, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(mqttTask, "MQTT", 4096, NULL, 1, NULL, 0);
}This pattern scales well — it's the same structure you'd use for a multi-sensor air quality node (like the setup in our <a href="https://www.xloge.site/2026/07/esp32-c6-indoor-air-quality-monitor.html">ESP32-C6 indoor air quality build</a>) or for feeding camera frames to a TinyML inference task, as in our <a href="https://www.xloge.site/2026/08/esp32-s3-edge-ai-camera-how-it-works.html">ESP32-S3 edge AI camera guide</a> — just swap the queue's data type for whatever you're passing between tasks.
Common Mistakes That Cause Crashes and Watchdog Resets
Most ESP32 "random crash" reports trace back to one of these:
Stack overflow. Shows up as ***ERROR*** A stack overflow in task <name> has been detected, sometimes followed by a completely unrelated-looking crash because the overflow corrupted adjacent memory. Fix: increase the task's stack size and check uxTaskGetStackHighWaterMark() as described earlier. Large local arrays, String concatenation, and sprintf/printf-heavy code are the usual culprits — they allocate on the stack, not the heap.
Watchdog timeout. If a task runs too long without yielding — a tight loop with no vTaskDelay() — the Task Watchdog Timer assumes it's hung and resets the board. Any loop that does real work needs at least an occasional vTaskDelay(1) to hand control back to the scheduler.
Using delay() instead of vTaskDelay(). delay() still works inside a FreeRTOS task, but it blocks the task rather than yielding it — you lose the responsiveness FreeRTOS is supposed to give you, and on a busy system it can contribute to watchdog trips.
Calling non-ISR-safe functions from an interrupt handler. Functions like Serial.print() or xQueueSend() aren't safe inside an ISR — you need the FromISR variants (xQueueSendFromISR, vTaskNotifyGiveFromISR, etc.), and any that can wake a task need to end with portYIELD_FROM_ISR().
Overloading Core 0. Because Wi-Fi and Bluetooth processing happens on Core 0 internally, pinning heavy application logic there too can cause intermittent Wi-Fi stability issues — dropped packets, slow reconnects, or the kind of instability reported in ESP32 Wi-Fi throughput issues on Espressif's own GitHub tracker. Keep Core 0 for networking and pin your compute-heavy tasks to Core 1.
Task Priorities: How They Actually Behave
Priority isn't "importance" in the everyday sense — it's how the scheduler picks which ready task to run. Higher numbers mean higher priority. A higher-priority task will always preempt a lower-priority one the instant it becomes ready to run. This is powerful but also a common source of bugs: if a high-priority task never blocks (never calls vTaskDelay, never waits on a queue), it will starve every lower-priority task completely, including the Arduino loopTask itself. Keep time-critical tasks (like handling an interrupt-driven event) at a higher priority, and give background tasks (like periodic logging) a lower one — and make sure every task actually blocks or delays at some point in its loop.
FreeRTOS Communication Methods at a Glance
| Method | Carries data? | Best for |
|---|---|---|
| Queue | Yes | Passing sensor readings, messages, or structs between tasks |
| Binary semaphore | No | Signaling a single event (e.g., "ISR fired") |
| Counting semaphore | No (counts only) | Managing access to a limited pool of resources |
| Mutex | No | Protecting a shared peripheral (I2C, SPI, SD card) from concurrent access |
| Task notification | Optional (32-bit value) | Fast, low-overhead signaling to one known task |
| Event group | No (bit flags) | Waiting on multiple conditions at once (e.g., "Wi-Fi AND NTP ready") |
| Software timer | No | Running a callback on a schedule without a dedicated task |
FAQs
Do I need to install FreeRTOS separately for the ESP32? No. FreeRTOS is already built into the ESP32 Arduino core and ESP-IDF — your Arduino sketch is already running as a FreeRTOS task before you write a single line of task code yourself.
What's the difference between xTaskCreate and xTaskCreatePinnedToCore?
xTaskCreate lets the scheduler choose which core runs the task. xTaskCreatePinnedToCore locks the task to a specific core (0 or 1), which matters when you need predictable timing or want to keep application logic off the core handling Wi-Fi/Bluetooth.
How many tasks can I run on an ESP32? There's no hard task-count limit in FreeRTOS itself — the real constraint is available RAM, since every task reserves its own stack. In practice, most ESP32 projects run somewhere between 3 and 10 tasks comfortably.
Why does my task crash with a stack overflow even though it looks simple?
Library calls inside the task (Wi-Fi, TLS, JSON parsing, String operations) often allocate large buffers on the stack without it being obvious from your own code. Increase the stack size and confirm the fix with uxTaskGetStackHighWaterMark().
Should I use a queue or a mutex to share a sensor value between tasks? Use a queue if one task is producing values and another is consuming them (data hand-off). Use a mutex if multiple tasks need to read or write the same shared resource, like a bus or a struct they all touch directly.
Can I use delay() in an Arduino ESP32 sketch that also uses FreeRTOS tasks?
You can, but it blocks that task entirely instead of yielding to others — use vTaskDelay(pdMS_TO_TICKS(ms)) in any task where you want other tasks to keep running during the wait.
What causes an ESP32 to reboot with a Task Watchdog message?
A task is running too long without yielding control back to the scheduler — usually a loop missing a vTaskDelay() call, or a blocking operation (like a long SPI transfer or network call) with no timeout on a high-priority task.
Is it safe to call Serial.print() inside an interrupt handler?
No. Serial.print() isn't ISR-safe and can cause crashes or hangs if called directly from an ISR. Set a flag or send a task notification from the ISR instead, and do the printing in a regular task.
Final Takeaway
FreeRTOS is what makes the ESP32 capable of doing several real-time things at once without you hand-rolling a scheduler. Start with tasks pinned to the right core, move data between them with queues instead of shared variables, protect shared peripherals with mutexes, and keep an eye on stack usage before it becomes a 2 a.m. debugging session. Once these patterns click, most of the "random ESP32 crash" problems you'll run into elsewhere — including the deep sleep and power issues covered in <a href="https://www.xloge.site/2026/09/why-your-esp32-wont-go-below-few.html">our ESP32 deep sleep troubleshooting guide</a> — start making a lot more sense too.
