When you search for an "Arduino wait" function, the IDE autosuggests delay(). But delay() is a trap for anything beyond blinking a single LED. It halts the CPU entirely, preventing sensor reads, dropping serial data, and starving network stacks. To implement a true non-blocking wait, you must use millis() for software-based state machines, or hardware timers (like the ESP32's Ticker or AVR's TimerOne) for microsecond precision.
This guide targets the Arduino Nano V3 (ATmega328P, 16MHz) and the ESP32 DevKit V1 (WROOM-32, 240MHz). We will build a non-blocking blink-and-debounce circuit, dissect the 32-bit unsigned math that makes it work, and debug the exact watchdog errors that occur when you accidentally block the loop.
Timing Methods Compared: delay() vs millis() vs Hardware Timers
Choosing the right wait mechanism depends on your required resolution and whether the CPU needs to perform background tasks (like maintaining a WiFi connection or reading an I2C sensor) during the wait. The table below breaks down the exact specifications for each method.
| Method | Resolution | CPU State During Wait | Jitter / Drift | Best Use Case |
|---|---|---|---|---|
delay(ms) |
~1ms | Blocked (Halted) | None (Exact block) | Setup routines, simple one-off tests |
millis() |
1ms | Free (Running) | Low (Accumulates if loop is slow) | UI debouncing, LED blinking, general state machines |
micros() |
4µs (AVR) / 1µs (ESP32) | Free (Running) | Very Low | Fast PWM generation, short sensor pulse timing |
AVR TimerOne |
Hardware dependent | Free (Interrupt driven) | Zero (Hardware clock) | Precise stepper motor stepping, audio synthesis |
ESP32 Ticker |
~1µs (RTOS scheduled) | Free (Task scheduled) | Low (Subject to RTOS jitter) | Telemetry polling, periodic sensor reads |
millis() is the 32-bit unsigned integer overflow, which happens every ~49.7 days. Never use if (currentMillis > previousMillis + interval). When currentMillis rolls over to 0, this logic breaks. Instead, always use subtraction: if (currentMillis - previousMillis >= interval). In unsigned 32-bit math, subtracting a large number near the overflow limit from a small rolled-over number correctly yields the exact elapsed time.
Parts List & Pin Mapping for Non-Blocking Wait Demo
To demonstrate a non-blocking wait, we will build a circuit that blinks an LED while simultaneously debouncing a tactile switch. If we used delay() for the blink, we would miss button presses. With millis(), both run concurrently.
Bill of Materials
- Microcontroller: Arduino Nano V3 (ATmega328P) OR ESP32 DevKit V1 (WROOM-32)
- LEDs: 5mm Red LED (x2) or use the onboard LED
- Resistors: 330Ω 1/4W (for LED current limiting)
- Switch: 6x6mm Tactile Pushbutton
- Pull-up Resistor: 10kΩ (Optional, as we will use internal pull-ups)
Pin Mapping Table
| Component | Arduino Nano V3 Pin | ESP32 DevKit V1 Pin | Configuration Notes |
|---|---|---|---|
| External LED Anode | D5 | GPIO 16 | Use 330Ω resistor in series |
| External LED Cathode | GND | GND | Common ground |
| Tactile Switch Leg 1 | D2 | GPIO 15 | Configured as INPUT_PULLUP |
| Tactile Switch Leg 2 | GND | GND | Pulls pin LOW when pressed |
The millis() State Machine: Complete Compilable Code
The following code uses a state-machine approach to handle both the LED blink interval and the button debounce wait. It is fully compilable for both the Nano V3 and the ESP32 DevKit V1 without modification, utilizing preprocessor directives to map the correct pins.
// Non-blocking Arduino Wait using millis()
// Targets: Arduino Nano V3 (ATmega328P) and ESP32 DevKit V1
#include <Arduino.h>
// Pin Definitions based on board architecture
#if defined(ESP32)
#define LED_PIN 16 // GPIO16 for external LED
#define BUTTON_PIN 15 // GPIO15 (supports internal pull-up)
#else
#define LED_PIN 5 // D5 for external LED
#define BUTTON_PIN 2 // D2 (supports internal pull-up)
#endif
// Timing Variables (must be unsigned long to handle 50-day rollover)
unsigned long previousLedMillis = 0;
unsigned long previousButtonMillis = 0;
const unsigned long LED_INTERVAL = 500; // 500ms blink
const unsigned long DEBOUNCE_INTERVAL = 50; // 50ms debounce wait
bool ledState = LOW;
bool lastButtonState = HIGH;
bool currentButtonState = HIGH;
void setup() {
Serial.begin(115200);
// Non-blocking serial wait: wait up to 3 seconds for Serial monitor
unsigned long startWait = millis();
while (!Serial && (millis() - startWait < 3000)) {
// Yield to background tasks on ESP32
#if defined(ESP32)
yield();
#endif
}
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.println(F("Non-blocking wait initialized."));
}
void loop() {
unsigned long currentMillis = millis();
// 1. Non-blocking LED blink wait
if (currentMillis - previousLedMillis >= LED_INTERVAL) {
previousLedMillis = currentMillis;
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
// 2. Non-blocking button debounce wait
bool reading = digitalRead(BUTTON_PIN);
// If the switch changed, due to noise or pressing:
if (reading != lastButtonState) {
previousButtonMillis = currentMillis; // Reset debounce timer
}
// Check if the debounce wait time has passed
if ((currentMillis - previousButtonMillis) > DEBOUNCE_INTERVAL) {
// If the state actually changed:
if (reading != currentButtonState) {
currentButtonState = reading;
if (currentButtonState == LOW) {
Serial.println(F("Button pressed (debounced without blocking)."));
}
}
}
lastButtonState = reading;
}
Debugging: Watchdog Timeouts and Timing Drift
When transitioning from delay() to non-blocking waits, developers often accidentally leave hidden blocking calls in their code. On the ESP32, this triggers the hardware watchdog, resulting in a catastrophic crash. On the AVR Nano, it simply causes the sketch to freeze or miss sensor data.
The Exact Error String
If you block the ESP32's loop() for more than a few seconds (or block interrupts for >300ms), the serial monitor will output:
Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
or
E (12345) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
Ranked Causes of Watchdog Failures
- Hidden Blocking Serial Calls: Using
Serial.readString()orSerial.parseInt()without first callingSerial.setTimeout(). The default timeout is 1000ms, which can easily trip the Task Watchdog if called repeatedly. - Infinite Pin-Waiting Loops: Writing
while(digitalRead(SENSOR_PIN) == HIGH) {}to wait for a sensor. If the sensor fails or the wire breaks, the loop never exits, and the watchdog fires. - I2C Bus Lockups: Calling
Wire.requestFrom()on an I2C device that is missing pull-up resistors. The SDA line stays low, and the AVR/ESP32 I2C library waits indefinitely for the clock to pulse.
The First Three Things to Check When It Fails
- Grep your codebase for blocking keywords: Search for
delay,while(,readString, andparseInt. Replace them with state-machine equivalents or add strict timeouts. - Measure I2C idle voltages: Use a multimeter to check the SDA and SCL lines. They should read ~3.3V (ESP32) or ~5V (Nano) when idle. If they read near 0V, you are missing 4.7kΩ pull-up resistors, and your next I2C call will hang the CPU.
- Feed the dog manually (Temporary Fix): If you have a mathematically heavy loop that must block for a second (e.g., calculating a large FFT), insert
yield();(ESP32) orwdt_reset();(AVR) inside the heavy loop to manually reset the watchdog timer.
Extending and Simplifying the Build
While the millis() state machine is the foundational skill for embedded timing, managing dozens of previousMillis variables in a complex project becomes unwieldy. Here is how you simplify and extend this architecture based on your target board.
Simplifying: Hardware Timer Libraries
If you only need to trigger a function at a fixed interval and don't need complex state-machine logic, abstract the math away using hardware timer libraries.
- For ESP32: Use the built-in
Tickerlibrary. It attaches a function to a hardware timer interrupt. Warning: Keep the callback function extremely short (e.g., set a boolean flag). Do not runSerial.print()or I2C reads inside an ISR (Interrupt Service Routine). - For Arduino Nano: Use the
TimerOnelibrary. It configures the ATmega328P's 16-bit Timer1 to fire an interrupt at precise microsecond intervals, completely independent of the mainloop()execution speed.
Extending: Moving to FreeRTOS (ESP32 Only)
The ESP32 is a dual-core powerhouse running FreeRTOS under the hood. If your project involves WiFi, Bluetooth, and multiple sensors, abandon millis() entirely and use RTOS tasks. According to the Espressif FreeRTOS documentation, using vTaskDelay() yields the CPU to the scheduler, allowing the WiFi stack to run on Core 0 while your sensor logic runs on Core 1.
// FreeRTOS non-blocking wait example (ESP32 only)
void sensorTask(void * parameter) {
for(;;) {
// Read sensor
int value = analogRead(34);
Serial.println(value);
// Non-blocking wait that yields to the RTOS scheduler
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void setup() {
Serial.begin(115200);
// Pin task to Core 1, priority 1
xTaskCreatePinnedToCore(sensorTask, "SensorTask", 2048, NULL, 1, NULL, 1);
}
void loop() {
// Loop is now free to handle WiFi/OTA updates
vTaskDelay(pdMS_TO_TICKS(1000));
}
By mastering the non-blocking Arduino wait, you transition from writing fragile hobby scripts to building robust, production-grade firmware that won't crash when a sensor takes an extra millisecond to respond. For deeper reading on AVR timing mechanics, refer to the official Arduino millis() reference and the ESP32 Watchdog Timer API docs.






