Most ESP32 sketches never mention which core they're running on, and that's by design -- the Arduino framework quietly handles it for you. But the ESP32 genuinely has two independent CPU cores under the hood, and understanding what's actually running where is the difference between a sketch that occasionally stutters under WiFi load and one that stays responsive no matter what the network is doing.
Where Your Code Actually Runs by Default
The Arduino-ESP32 core is built on top of FreeRTOS, a real-time operating system, and every sketch is already multitasking whether you've written a single task or not. Internally, setup() and loop() are wrapped in a FreeRTOS task called loopTask, which is pinned to Core 1 (labeled APP_CPU in Espressif's documentation) by default. Core 0 (PRO_CPU) is where the WiFi and Bluetooth stacks run their own background tasks, largely invisible to your sketch.
| Core | Espressif name | Default occupant |
|---|---|---|
| Core 0 | PRO_CPU | WiFi/Bluetooth stack, system tasks |
| Core 1 | APP_CPU | Your setup()/loop() code (as loopTask) |
This default split is exactly why a sketch that does a lot of blocking work inside loop() can still receive WiFi packets and serve HTTP requests reasonably well -- that traffic is largely being handled on the other core the whole time, mostly independent of whatever loop() is doing.
Creating Your Own Task on a Specific Core
Once a sketch needs to do two genuinely independent things at once -- read a sensor continuously while also running a slower, blocking display update, for instance -- creating a second FreeRTOS task explicitly, and pinning it to a chosen core, is the right tool.
TaskHandle_t sensorTaskHandle;
void sensorTask(void *parameter) {
for (;;) {
int value = analogRead(34);
Serial.printf("Core %d reading: %d\n", xPortGetCoreID(), value);
vTaskDelay(200 / portTICK_PERIOD_MS); // FreeRTOS-safe delay, not delay()
}
}
void setup() {
Serial.begin(115200);
xTaskCreatePinnedToCore(
sensorTask, // function to run
"SensorTask", // name, for debugging
2048, // stack size in bytes
NULL, // parameter to pass in
1, // priority (0 = lowest)
&sensorTaskHandle, // task handle, for later reference
0 // core to pin to -- Core 0 here
);
}
void loop() {
// The default loopTask keeps running on Core 1, independent of sensorTask
Serial.println("Core 1 doing its own thing");
delay(500);
}
With this running, the sensor reads happen on Core 0 on their own schedule, completely independent of whatever loop() is doing on Core 1 -- neither one blocks the other.
Priorities, Stack Size, and the Watchdog
The 1 in xTaskCreatePinnedToCore above is the task's priority -- higher numbers preempt lower ones on the same core. Most simple sensor or blink tasks are fine at low priorities (0-2); reserve higher priorities for genuinely time-critical work, since a high-priority task that never yields can starve everything else on its core.
The 2048 is stack size in bytes, not the same thing as the heap. Too small and a task will silently corrupt memory or crash with a stack overflow that's hard to trace; too large wastes RAM the ESP32 doesn't have much of to begin with. Doubling the value and watching for stability is a reasonable way to tune it when unsure.
Every core also runs a Task Watchdog Timer, which resets the chip if a task doesn't yield for too long. A task with an infinite for(;;) loop and no vTaskDelay() (or any other yielding call) inside it will eventually trip the watchdog and reboot the board -- this is one of the most common "my ESP32 randomly restarts" reports, and the fix is almost always adding a delay call the scheduler can use to give other tasks their turn.
Passing Data Between Cores Safely
A plain global variable can be read and written from tasks on different cores, but doing so without synchronization risks a race condition -- one core reading a value halfway through the other core writing it. A queue is the standard, safe way to move data between tasks, including across cores:
QueueHandle_t sensorQueue;
void producerTask(void *parameter) {
int reading;
for (;;) {
reading = analogRead(34);
xQueueSend(sensorQueue, &reading, portMAX_DELAY);
vTaskDelay(200 / portTICK_PERIOD_MS);
}
}
void consumerTask(void *parameter) {
int received;
for (;;) {
if (xQueueReceive(sensorQueue, &received, portMAX_DELAY)) {
Serial.printf("Got reading: %d on core %d\n", received, xPortGetCoreID());
}
}
}
void setup() {
Serial.begin(115200);
sensorQueue = xQueueCreate(10, sizeof(int));
xTaskCreatePinnedToCore(producerTask, "Producer", 2048, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(consumerTask, "Consumer", 2048, NULL, 1, NULL, 1);
}
void loop() {
vTaskDelete(NULL); // loopTask isn't needed here; both tasks run independently
}
This pattern -- a producer task on one core feeding a queue, a consumer task on the other draining it -- scales cleanly to most real dual-core designs, from sensor logging to keeping a display updated without stalling a network task.
Common Mistakes
Assuming loop() itself runs on both cores. It doesn't -- it's a single FreeRTOS task pinned to Core 1. Genuine parallelism requires creating additional tasks explicitly.
Sharing a variable across cores with no synchronization. Works most of the time in testing, then fails intermittently in the field -- exactly the signature of a race condition. Use a queue, a mutex, or at minimum a volatile qualifier plus careful reasoning about atomicity.
Writing an infinite task loop with no yield. Missing vTaskDelay() (or equivalent) trips the watchdog and reboots the chip, often reported as "random ESP32 crashes" without an obvious cause.
Over-prioritizing tasks. A high-priority task that rarely yields can starve lower-priority tasks on the same core, including system tasks -- default to low priorities unless a task genuinely needs to preempt others.
Putting heavy blocking work on Core 0 unnecessarily. Core 0 is already carrying the WiFi/Bluetooth stack; piling a slow, blocking task onto the same core can degrade network responsiveness even though your sketch code appears to be running elsewhere.
Where to Go Next
Dual-core task management becomes genuinely useful the moment a project needs to do two things with different timing requirements at once -- reading a sensor at a fixed interval while serving a web page, for example. The ESP32 Complete Beginner's Guide covers the WiFi and web server code this pairs naturally with, and ESP32 I2C pins and multiple devices is a common thing to move onto a dedicated task once a sketch grows past a single sensor.