The arduino delay() function is the first timing tool most makers learn, and the first one that breaks their project. When you need to blink an LED, delay(1000) works perfectly. But the moment you try to blink that LED while simultaneously reading a DHT22 temperature sensor and listening for a button press, your system stutters. The direct answer to this problem is simple: delay() halts the CPU, making concurrent tasks impossible. To fix it, you must replace blocking delays with non-blocking state machines driven by millis().
In this guide, we will tear down the mechanics of the arduino delay() function, compare it against hardware and software alternatives, and build a fully non-blocking pedestrian traffic light. We will also cover the exact compiler errors and logical traps that cause millis() implementations to fail on the bench.
The Blocking Problem: Why Arduino delay() Fails
Under the hood, the Arduino delay() function is a simple while loop that continuously checks the system tick counter until the requested milliseconds have passed. During this loop, the ATmega328P (or ESP32) CPU does absolutely nothing else. It cannot read GPIO pins, it cannot process incoming UART/I2C data, and it cannot update PWM outputs.
If you use a 500ms delay to debounce a button, your microcontroller is effectively deaf and blind for half a second. In a simple toy, this is fine. In a motor controller or a multi-sensor data logger, a 500ms CPU halt can cause missed encoder steps, buffer overflows, or watchdog timer resets.
Timing Method Comparison Matrix
Before writing a single line of code, you need to choose the right timing mechanism for your architecture. Here is how the standard arduino delay() stacks up against non-blocking alternatives on a standard 16MHz AVR board.
| Timing Method | Resolution | CPU State | Max Duration | Best Use Case |
|---|---|---|---|---|
delay(ms) |
1 ms | Halted (Blocking) | ~49.7 days | Setup routines, simple single-task toys |
millis() |
1 ms | Active (Polling) | ~49.7 days (Rollover) | State machines, UI debounce, sensor polling |
micros() |
4 µs (at 16MHz) | Active (Polling) | ~70 minutes (Rollover) | Pulse-width measurement, high-speed PID loops |
TimerOne (ISR) |
1 µs | Active (Interrupt) | ~8.3 seconds | Precision waveform generation, stepper stepping |
vTaskDelay (RTOS) |
1 ms (config) | Yielded to Scheduler | Infinite (Handled by OS) | ESP32 multitasking, complex concurrent systems |
delayMicroseconds() for durations longer than a few thousand microseconds. It disables interrupts on AVR boards, which will corrupt your millis() counter and break serial communication.
Hardware Build: Non-Blocking Pedestrian Traffic Light
To demonstrate non-blocking timing, we are building a traffic light that cycles automatically but allows a pedestrian button to immediately trigger a state change. If we used delay() for the green light (e.g., 5 seconds), pressing the button during those 5 seconds would do nothing. By using millis(), the button is read every single loop iteration.
Parts List
- Microcontroller: Arduino Uno R3 (DIP-28 ATmega328P variant)
- LEDs: 3x 5mm Diffused LEDs (Red, Yellow, Green)
- Current Limiting: 3x 220Ω 1/4W Carbon Film Resistors
- Input: 1x 6x6mm Tactile Pushbutton Switch
- Pull-up: 1x 10kΩ Resistor (if not using internal pull-ups)
Pin Mapping Table
| Component | Arduino Uno Pin | Mode | Notes |
|---|---|---|---|
| Red LED Anode | D8 | OUTPUT | 220Ω resistor in series |
| Yellow LED Anode | D9 | OUTPUT | 220Ω resistor in series |
| Green LED Anode | D10 | OUTPUT | 220Ω resistor in series |
| Pedestrian Button | D2 | INPUT_PULLUP | Wired to GND, active LOW |
The Code: millis() Implementation with Debounce
The following code targets the Arduino Uno R3 (ATmega328P). It uses a state machine to manage the light sequence and a separate millis() tracker to handle switch debouncing without halting the CPU. Notice that all time-tracking variables are declared as unsigned long.
// Pin Definitions
#define PIN_RED_LED 8
#define PIN_YELLOW_LED 9
#define PIN_GREEN_LED 10
#define PIN_BUTTON 2
// Timing Intervals (milliseconds)
const unsigned long GREEN_DURATION = 5000;
const unsigned long YELLOW_DURATION = 2000;
const unsigned long RED_DURATION = 5000;
const unsigned long DEBOUNCE_DELAY = 50;
// State Machine
enum LightState { STATE_GREEN, STATE_YELLOW, STATE_RED };
LightState currentState = STATE_GREEN;
// Timing Variables (Must be unsigned long!)
unsigned long previousMillis = 0;
unsigned long currentInterval = GREEN_DURATION;
// Button Debounce Variables
int lastButtonState = HIGH;
int currentButtonState = HIGH;
unsigned long lastDebounceTime = 0;
void setup() {
pinMode(PIN_RED_LED, OUTPUT);
pinMode(PIN_YELLOW_LED, OUTPUT);
pinMode(PIN_GREEN_LED, OUTPUT);
pinMode(PIN_BUTTON, INPUT_PULLUP);
// Initialize to Green
digitalWrite(PIN_GREEN_LED, HIGH);
previousMillis = millis();
}
void loop() {
unsigned long currentMillis = millis();
// 1. Handle Non-Blocking Button Debounce
int reading = digitalRead(PIN_BUTTON);
if (reading != lastButtonState) {
lastDebounceTime = currentMillis; // Reset timer on any edge
}
// Check if debounce period has passed
if ((currentMillis - lastDebounceTime) > DEBOUNCE_DELAY) {
if (reading != currentButtonState) {
currentButtonState = reading;
// Button is pressed (Active LOW due to INPUT_PULLUP)
if (currentButtonState == LOW && currentState == STATE_GREEN) {
// Force immediate transition to Yellow
currentState = STATE_YELLOW;
currentInterval = YELLOW_DURATION;
previousMillis = currentMillis; // Reset state timer
}
}
}
lastButtonState = reading;
// 2. Handle Non-Blocking State Machine Transitions
if (currentMillis - previousMillis >= currentInterval) {
previousMillis = currentMillis;
switch (currentState) {
case STATE_GREEN:
currentState = STATE_YELLOW;
currentInterval = YELLOW_DURATION;
break;
case STATE_YELLOW:
currentState = STATE_RED;
currentInterval = RED_DURATION;
break;
case STATE_RED:
currentState = STATE_GREEN;
currentInterval = GREEN_DURATION;
break;
}
}
// 3. Update Physical Outputs based on State
digitalWrite(PIN_GREEN_LED, (currentState == STATE_GREEN) ? HIGH : LOW);
digitalWrite(PIN_YELLOW_LED, (currentState == STATE_YELLOW) ? HIGH : LOW);
digitalWrite(PIN_RED_LED, (currentState == STATE_RED) ? HIGH : LOW);
}
Debugging Timing Errors: First Three Things to Check
When transitioning from delay() to millis(), makers frequently introduce subtle bugs that cause the system to lock up or behave erratically after hours of runtime. If your non-blocking code fails, check these three things first.
1. The Signedness Compiler Warning
If you declare your timing variables as standard int or long instead of unsigned long, the GCC compiler will throw this exact error string:
warning: comparison of integer expressions of different signedness: 'long unsigned int' and 'int' [-Wsign-compare]
The Fix: millis() returns an unsigned long (32-bit, 0 to 4,294,967,295). If you compare it to a signed int (max 32,767 on AVR), your logic will break catastrophically after just 32 seconds. Always use unsigned long for any variable touching millis().
2. The Rollover Logic Trap
The millis() counter overflows and resets to zero approximately every 49.7 days. If you write your logic like this:
// BAD: Fails on rollover
if (currentMillis > previousMillis + interval) { ... }
When previousMillis + interval exceeds 4,294,967,295, it overflows into a small number, and the if statement will trigger continuously or lock up entirely.
The Fix: Always use subtraction, as shown in the code above: if (currentMillis - previousMillis >= interval). Due to the mathematical properties of unsigned binary arithmetic, subtraction automatically handles the rollover boundary perfectly. As Nick Gammon's definitive guide on Arduino timing explains, the underflow wraps around to yield the correct positive delta.
3. Hidden Blocking Functions in the Loop
You might perfectly implement millis() for your LEDs, only to find your button still feels sluggish. This happens when you hide a blocking function elsewhere in the loop(). Common culprits include:
Serial.readString()orSerial.parseInt()(blocks until timeout, default 1000ms)Wire.requestFrom()without checking for I2C bus lockupsdht.readTemperature()(The DHT library disables interrupts and blocks for ~250ms per read)
The Fix: Audit your entire loop() execution path. Replace Serial.readString() with a non-blocking serial event parser, and move slow sensors like the DHT22 onto their own millis() timers so they only execute once every 2 seconds.
millis() watchdog won't save you. Always use hardware interlocks or thermal fuses for high-power loads.
How to Extend or Simplify the Build
Writing raw millis() state machines is a rite of passage, but as your project scales to 10 or 15 concurrent tasks, managing dozens of previousMillis variables becomes a nightmare. Here is how to adapt your architecture based on your complexity needs.
Simplifying: Use a Task Scheduler Library
If you want to stay on the Arduino Uno but hate managing state variables, install the TaskScheduler library via the Arduino Library Manager. It allows you to define tasks as callbacks and lets the library handle the millis() math and rollover protection.
#include
void checkButton() { /* non-blocking button logic */ }
void updateLights() { /* state machine logic */ }
Task t1(50, TASK_FOREVER, &checkButton);
Task t2(10, TASK_FOREVER, &updateLights);
Scheduler runner;
void setup() {
runner.addTask(t1);
runner.addTask(t2);
t1.enable();
t2.enable();
}
void loop() {
runner.execute(); // Replaces all millis() logic
}
Extending: Move to ESP32 and FreeRTOS
If your project requires true multitasking—such as streaming audio while maintaining a WebSocket connection and blinking LEDs—the 8-bit AVR architecture is the wrong tool. Upgrade to an ESP32-WROOM-32 dev board and utilize FreeRTOS.
On the ESP32, you replace millis() polling with vTaskDelay(). Unlike arduino delay(), vTaskDelay() does not halt the CPU; it yields the current task back to the RTOS scheduler, allowing other tasks to run on the ESP32's dual cores. As detailed in Adafruit's multitasking guides, moving to an RTOS fundamentally shifts your mental model from "checking the clock" to "managing thread priorities."
Mastering non-blocking timing is the exact threshold that separates beginners who can only build blinking toys from intermediate engineers who can build robust, production-ready embedded systems. Ditch the delay(), embrace the state machine, and your microcontroller will finally be able to walk and chew gum at the same time.






