When you need to wait in Arduino, the delay() function is a prototype trap. It halts the CPU, drops incoming serial data, misses button presses, and starves watchdog timers. For production firmware, you must implement a non-blocking wait. The direct answer: use millis() for standard millisecond-resolution polling, micros() for tight timing loops, and hardware timers (like the ESP32's hw_timer_t) for background interrupts that must execute regardless of the main loop state.
This guide targets the Arduino Nano ESP32 (featuring the ESP32-S3 chip), which bridges the classic Arduino IDE ecosystem with modern RTOS and hardware-timer capabilities. We will build a multi-sensor poller that reads environmental data and handles button debouncing simultaneously, without a single delay() call.
The Waiting Matrix: delay() vs millis() vs Hardware Timers
Before wiring the board, you must choose the right waiting mechanism. The table below breaks down the architectural trade-offs of each method available in the Arduino framework as of 2026.
| Method | Resolution | Blocking? | CPU Overhead | Best Use Case | Known Failure Mode |
|---|---|---|---|---|---|
delay(ms) |
1 ms | Yes (Hard) | Zero (Yields to RTOS) | Boot sequences, one-off hardware resets | Missed interrupts, WDT resets |
millis() |
1 ms | No | Low (Polled in loop) | LED blinking, sensor polling, state machines | Unsigned long overflow at 49.7 days |
micros() |
4 μs (AVR) / 1 μs (ESP32) | No | Low (Polled in loop) | PWM generation, ultrasonic distance sensing | Overflow every 70 minutes |
hw_timer_t (ESP32) |
Sub-microsecond | No (Interrupt) | High (Context switching) | Encoder counting, precise waveform generation | ISR panic if using non-IRAM functions |
vTaskDelay() (FreeRTOS) |
1 ms (Tick rate) | Yes (Soft/Thread) | Zero (Yields core to other tasks) | Dual-core ESP32 multitasking, WiFi stacks | Priority inversion, stack overflow |
millis() is the gold standard. It keeps the main loop running fast enough to service the WiFi stack while strictly timing your sensor reads. Reserve hardware timers for tasks where a 2ms jitter will ruin your data.
Parts List & Pin Mapping
This build uses the Arduino Nano ESP32 due to its native I2C support and 3.3V logic, which pairs perfectly with modern environmental sensors without needing level shifters.
| Component | Exact Model / Variant | Pin Connection (Nano ESP32) | Notes & Specs |
|---|---|---|---|
| Microcontroller | Arduino Nano ESP32 (ABX00092) | N/A | ESP32-S3, 3.3V logic, 8MB Flash |
| Env. Sensor | Adafruit BME280 (PID 2652) | VIN=3V3, GND=GND, SCL=A5, SDA=A4 | I2C Addr: 0x77. Requires 4.7kΩ pull-ups (built-in on Adafruit breakout) |
| Status LED | Standard 5mm LED + 330Ω Resistor | Anode=D2, Cathode=GND | Non-blocking blink indicator |
| User Input | 12x12mm Tactile Switch | Pin 1=D4, Pin 2=GND | Configured with internal pull-up |
Step-by-Step: Wiring the Non-Blocking Poller
- Prep the Breadboard: Insert the Arduino Nano ESP32 across the center trench. Connect the 3.3V pin to the positive rail and GND to the negative rail. Note: Do not use the 5V pin for the BME280; it is a 3.3V sensor and 5V will degrade the humidity membrane over time.
- Wire the I2C Bus: Connect the BME280 SDA to Nano pin A4, and SCL to Nano pin A5. Connect VIN to 3.3V and GND to GND.
- Wire the Indicator: Place the 330Ω resistor from Nano pin D2 to an empty row. Connect the LED anode (long leg) to the resistor, and cathode (short leg) to GND.
- Wire the Button: Connect one leg of the tactile switch to Nano pin D4, and the diagonal opposite leg to GND. We will enable the internal pull-up resistor in code, eliminating the need for an external 10kΩ resistor.
- Verify Connections: Use a multimeter in continuity mode to verify there are no shorts between the 3.3V rail and GND before plugging in the USB-C cable.
Complete Code: Non-Blocking Wait with Error Handling
The following code targets the Arduino Nano ESP32 using the Arduino IDE (ensure you have the 'Arduino ESP32 Boards' core installed via Boards Manager, version 3.0.0 or newer). It requires the Adafruit_BME280 and Adafruit_Unified_Sensor libraries.
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// --- PIN DEFINITIONS ---
const int LED_PIN = 2;
const int BUTTON_PIN = 4;
// --- TIMING VARIABLES (Must be unsigned long) ---
unsigned long previousSensorMillis = 0;
const long SENSOR_INTERVAL = 2000; // Read sensor every 2 seconds
unsigned long previousLedMillis = 0;
const long LED_INTERVAL = 500; // Blink LED every 500ms
unsigned long lastDebounceTime = 0;
const long DEBOUNCE_DELAY = 50; // 50ms debounce wait
// --- STATE VARIABLES ---
int ledState = LOW;
int buttonState = HIGH;
int lastButtonReading = HIGH;
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
// Wait for serial port to connect (Non-blocking style with a timeout)
unsigned long serialStart = millis();
while (!Serial && (millis() - serialStart < 3000)) {
// Yield to background tasks while waiting for USB serial
yield();
}
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// I2C Initialization with Error Handling
Wire.begin(A4, A5); // Explicitly define SDA, SCL for Nano ESP32
if (!bme.begin(0x77, &Wire)) {
Serial.println("[ERROR] Could not find a valid BME280 sensor!");
Serial.println("Check I2C wiring, pull-up resistors, and address (0x76 vs 0x77).");
// Blink LED rapidly to indicate fatal hardware error
while (1) {
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
delay(100); // delay() is acceptable here as the system is halted
}
}
Serial.println("[INFO] BME280 initialized. System running non-blocking loop.");
}
void loop() {
unsigned long currentMillis = millis();
// 1. NON-BLOCKING LED BLINK
if (currentMillis - previousLedMillis >= LED_INTERVAL) {
previousLedMillis = currentMillis;
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
// 2. NON-BLOCKING BUTTON DEBOUNCE
int reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonReading) {
lastDebounceTime = currentMillis;
}
if ((currentMillis - lastDebounceTime) > DEBOUNCE_DELAY) {
if (reading != buttonState) {
buttonState = reading;
if (buttonState == LOW) {
Serial.println("[EVENT] Button Pressed!");
}
}
}
lastButtonReading = reading;
// 3. NON-BLOCKING SENSOR POLLING
if (currentMillis - previousSensorMillis >= SENSOR_INTERVAL) {
previousSensorMillis = currentMillis;
float temp = bme.readTemperature();
float humidity = bme.readHumidity();
// Sanity check for I2C bus lockups (returns NAN on failure)
if (isnan(temp) || isnan(humidity)) {
Serial.println("[ERROR] BME280 read failed. I2C bus may be locked.");
} else {
Serial.printf("[DATA] Temp: %.2f C | Humidity: %.2f %%\n", temp, humidity);
}
}
}
Debugging Wait Failures: Top 3 Things to Check
When your non-blocking wait logic fails, the system usually doesn't crash; it just behaves erratically. Here are the first three things to check, including a notorious compiler error introduced in recent ESP32 core updates.
1. The Unsigned Long Overflow Trap (Logical Failure)
If your code stops executing a timed event after exactly 49.7 days, you have written your math wrong.
- Wrong:
if (millis() >= previousMillis + interval). WhenpreviousMillis + intervalexceeds 4,294,967,295, it overflows to 0, and the condition instantly evaluates to true, ruining your timing. - Right:
if (millis() - previousMillis >= interval). Because the variables areunsigned long, the subtraction handles the rollover wrap-around perfectly due to two's complement binary math.
2. The ESP32 Core v3.x Timer API Compiler Error
If you attempt to upgrade from millis() to a hardware timer using older tutorials, you will hit a massive breaking change in the Espressif Arduino Core v3.0.0. You will see this exact error string during compilation:
error: too many arguments to function 'hw_timer_t* timerBegin(uint32_t)'
The Fix: In Core v2.x, you configured the prescaler and divider: timerBegin(0, 80, true). In Core v3.x, the API was simplified to only accept the target frequency in Hz. Change your code to timerBegin(1000000) for a 1MHz (1μs) tick rate. Read the official ESP32 migration guide for the full API overhaul.
3. I2C Bus Lockup During Wait States
If your sensor returns NAN after running for a few hours, the I2C bus has likely locked up due to electrical noise on the SDA/SCL lines while the CPU was waiting. The BME280 thinks it's still transmitting, and the ESP32 thinks the bus is busy. The Fix: Add 4.7kΩ physical pull-up resistors to the SDA and SCL lines if your breakout board doesn't have them, and implement a software I2C reset routine in your error-handling block that toggles the SCL pin 9 times to clear the slave's buffer.
Extending and Simplifying the Build
Depending on your end goal, you can scale this architecture up or down.
How to Extend
- Add Deep Sleep: If running on battery, replace the
millis()wait with ESP32 deep sleep. Useesp_sleep_enable_timer_wakeup(2000000)to wait 2 seconds with the CPU completely powered down, drawing only ~10μA. - Add FreeRTOS: Move the sensor polling to Core 0 and the WiFi/Bluetooth stack to Core 1 using
xTaskCreatePinnedToCore(). This prevents WiFi transmission spikes from delaying your sensor reads.
How to Simplify
- Use a Library: If managing multiple
previousMillisvariables becomes messy, install the Ticker or TaskScheduler library via the Arduino Library Manager. They abstract themillis()math into clean callback functions. - Drop the Sensor: If you only need to debounce a button and blink an LED, strip out the Wire and BME280 includes. The core timing logic remains identical and compiles down to less than 5% of the Nano ESP32's flash.
Mastering the non-blocking wait in Arduino is the dividing line between a hobbyist sketch and a reliable embedded product. By respecting unsigned integer math and choosing the right timing primitive for your resolution needs, your firmware will remain responsive, robust, and ready for the field.






