ESP32 blink without delay: your first non-blocking firmware
Every ESP32 tutorial starts the same way — blink an LED with delay(). It works. The LED blinks. And then you try to add a second thing, and nothing works anymore.
This post explains exactly why, and shows you the fix. It also has a live simulator embedded below so you can run the code right here without any hardware.
The problem with delay()
delay(1000) tells the ESP32 to do absolutely nothing for one second. No reading sensors. No checking buttons. No receiving MQTT messages. The chip just sits there, frozen.
// This firmware can only do one thing at a time
void loop() {
digitalWrite(LED_PIN, HIGH);
delay(1000); // frozen for 1 second
digitalWrite(LED_PIN, LOW);
delay(1000); // frozen for another second
}
This is fine for a single LED. The moment you add anything else — a button, a sensor read, a serial command — you’ll notice the problem. Try pressing a button during the delay() call. The ESP32 doesn’t respond. It’s blocked.
The fix: millis()
millis() returns how many milliseconds have elapsed since the chip booted. It never blocks. You can call it a thousand times per loop and it costs nothing.
The pattern is: store the last time you did something, then check every loop whether enough time has passed to do it again.
const int LED_PIN = 2;
const long INTERVAL = 1000; // milliseconds
unsigned long lastToggle = 0;
bool ledState = false;
void setup() {
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
}
void loop() {
unsigned long now = millis();
if (now - lastToggle >= INTERVAL) {
lastToggle = now;
ledState = !ledState;
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
Serial.println(ledState ? "LED ON" : "LED OFF");
}
// Everything here runs freely — no blocking
}
The LED still toggles every second. But now the rest of loop() runs at full speed between toggles. You can read a sensor, check a button, process serial input — all without missing a beat.
Pro feature
Run this code in the live simulator, see serial output instantly — no install needed.
Why this matters in production
A polling loop built on millis() can handle multiple independent intervals cleanly:
const int LED_PIN = 2;
unsigned long lastBlink = 0;
unsigned long lastHeartbeat = 0;
bool ledState = false;
void setup() {
pinMode(LED_PIN, OUTPUT);
Serial.begin(115200);
}
void loop() {
unsigned long now = millis();
if (now - lastBlink >= 500) { // toggle LED every 500ms
lastBlink = now;
ledState = !ledState;
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
Serial.print("LED: ");
Serial.println(ledState ? "ON" : "OFF");
}
if (now - lastHeartbeat >= 3000) { // heartbeat every 3s
lastHeartbeat = now;
Serial.print("uptime ms: ");
Serial.println(now);
}
}
Each task runs at its own rate. No task blocks another. This is the foundation of all non-trivial firmware.
Pro feature
Run this code in the live simulator, see serial output instantly — no install needed.
The one trap to avoid
The subtraction trick now - lastToggle works even when millis() rolls over (overflows back to zero after ~49 days), because unsigned integer subtraction wraps correctly in C++. Do not write millis() >= lastToggle + INTERVAL — that breaks on rollover.
TIP Always use
now - lastTime >= INTERVALnotmillis() >= lastTime + INTERVAL. The subtraction form is rollover-safe. The addition form breaks after 49 days.
What’s next
Once you’re comfortable with millis(), the next step is FreeRTOS tasks — each task runs in its own loop with its own stack, and the RTOS handles scheduling. That’s the right tool when tasks need to be truly independent rather than just interleaved.
Read next: ESP32 FreeRTOS: tasks, queues and semaphores in plain English
Related posts
Comments
Enjoyed this tutorial?
Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.