The Hidden Cost of delay(): Why Modern MCUs Need Non-Blocking Code
For beginners entering the embedded systems space, the delay() function is a comforting crutch. Need to blink an LED? Add delay(1000). Need to debounce a button? Add delay(50). However, as projects evolve from simple blinking LEDs to complex 2026 IoT sensor nodes, motor controllers, and wireless telemetry systems, delay() becomes a critical liability. When an AVR or ARM microcontroller executes a delay, it halts all main-loop processing. It cannot read sensors, update displays, or respond to user inputs. In safety-critical or time-sensitive applications, a blocking CPU can trigger watchdog timer resets, miss vital hardware interrupts, or drop serial data packets.
The solution is non-blocking, asynchronous timing. At the heart of this paradigm in the Arduino ecosystem is the millis() function. By tracking elapsed time rather than pausing execution, millis() allows your microcontroller to multitask effectively, managing dozens of independent timed events simultaneously within a single loop() iteration.
Under the Hood: How Arduino millis() Actually Works
To use millis() robustly, you must understand what it is actually measuring. On classic 8-bit AVR boards like the Arduino Uno (ATmega328P), millis() relies on Hardware Timer0. The 16 MHz system clock is fed through a prescaler (typically set to 64), resulting in a 250 kHz tick rate, or one tick every 4 microseconds. An Interrupt Service Routine (ISR) fires on every overflow of the 8-bit timer register, incrementing a hidden 32-bit unsigned long variable maintained in the Arduino core library.
When you call millis(), the microcontroller briefly disables interrupts, reads this 32-bit variable, and returns the value. According to the official Arduino language reference, this function returns the number of milliseconds passed since the board began running the current program. Because it relies on a hardware timer and an ISR, it operates entirely in the background, completely independent of your main code's execution speed.
The 49.7-Day Time Bomb: Understanding Rollover
The most notorious pitfall of millis() is the rollover (or overflow) event. Because the underlying counter is a 32-bit unsigned long, its maximum value is 4,294,967,295. Once it hits this ceiling, it overflows and wraps around to zero.
Let us do the math: 4,294,967,295 milliseconds equates to roughly 4,294,967 seconds, which is 71,582 minutes, 1,193 hours, or exactly 49.71 days. If your device runs continuously for 49.7 days, the timer will reset to zero. For a smart thermostat or an industrial data logger, this is not a theoretical edge case; it is a guaranteed operational event.
The Naive (and Broken) Approach
Many developers attempt to handle timing using addition. This approach will catastrophically fail during a rollover event.
// THE WRONG WAY: Fails on rollover
unsigned long nextExecution = millis() + 1000;
void loop() {
if (millis() >= nextExecution) {
// Execute task
nextExecution = millis() + 1000;
}
}Imagine millis() is currently at 4,294,967,000 (about 295 milliseconds before rollover). You set nextExecution to millis() + 1000. The addition overflows the 32-bit boundary, wrapping nextExecution to a small number (e.g., 704). On the very next loop iteration, millis() is 4,294,967,001, which is vastly greater than 704. The condition triggers immediately, completely skipping your intended 1000ms delay, and potentially throwing your state machine into chaos.
The Golden Rule of millis() Math
To write rollover-safe code, you must never add to the current time. Instead, always subtract the past timestamp from the current time. This leverages the properties of unsigned integer underflow in C/C++.
// THE RIGHT WAY: Rollover-proof
unsigned long previousMillis = 0;
const long interval = 1000;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// Execute task safely
}
}Why does this work? Unsigned math wraps predictably. If currentMillis is 100 (just after rollover) and previousMillis was 4,294,967,200 (just before rollover), the subtraction 100 - 4,294,967,200 underflows and wraps around to exactly 196. The math naturally calculates the correct elapsed time across the zero-boundary without requiring complex conditional logic. This subtraction method is the cornerstone of the classic BlinkWithoutDelay architecture.
Comparison Matrix: Timing Strategies in 2026
While millis() is the standard for general-purpose non-blocking delays, modern maker projects utilizing 32-bit architectures (like the ESP32-S3 or Raspberry Pi Pico RP2040) have access to a broader toolkit. Here is how millis() compares to other timing mechanisms.
| Method | Resolution | Rollover Time | Best Use Case | Blocking? |
|---|---|---|---|---|
delay() | 1 ms | N/A | Simple setup routines, basic debouncing | Yes |
millis() | 1 ms | ~49.7 Days | State machines, sensor polling, UI updates | No |
micros() | 4 µs (AVR) | ~71.5 Minutes | PWM generation, PID control loops, sonar | No |
| Hardware Timers | Clock cycles | Configurable | High-speed ADC triggering, precise waveforms | No (ISR) |
| RTOS Tasks | 1 ms (Tick) | None (Managed) | Complex ESP32/STM32 multitasking, WiFi stacks | No |
Building a Multitasking State Machine
To truly harness millis(), you must decouple your logic from a linear sequence and adopt a state-machine mindset. Below is an architecture pattern for managing two completely independent tasks: reading a BME280 environmental sensor every 5 seconds, and toggling a status LED every 250 milliseconds.
unsigned long previousSensorRead = 0;
unsigned long previousLedToggle = 0;
const long sensorInterval = 5000;
const long ledInterval = 250;
void loop() {
unsigned long currentMillis = millis();
// Task 1: High-frequency LED toggle
if (currentMillis - previousLedToggle >= ledInterval) {
previousLedToggle = currentMillis;
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
}
// Task 2: Low-frequency Sensor Polling
if (currentMillis - previousSensorRead >= sensorInterval) {
previousSensorRead = currentMillis;
readBME280Sensor(); // Custom function
}
// Task 3: Continuous background processing (e.g., serial parsing)
processSerialCommands();
}Notice that currentMillis is captured once at the top of the loop. This ensures that all interval checks within that specific iteration use the exact same timestamp, preventing minor execution-time discrepancies from causing drift across multiple tasks.
Advanced Edge Cases: micros(), Interrupts, and 32-Bit ARM Cores
As you push the boundaries of your microcontroller, keep these critical edge cases in mind:
- The micros() Rollover: If your application requires microsecond precision (e.g., calculating distance via an HC-SR04 ultrasonic sensor or tuning a PID loop), you will use
micros(). Be aware that because a 32-bit integer fills up much faster at the microsecond scale,micros()rolls over every 71.58 minutes. The subtraction golden rule applies here exactly as it does tomillis(). - Interrupt Safety: If you are reading
millis()inside a custom ISR, be cautious. On 8-bit AVR chips, reading a 32-bit variable takes multiple clock cycles. If the Timer0 ISR fires while you are reading the bytes ofmillis()in your own ISR, you could read a corrupted, hybrid value. Always keep ISRs as short as possible and defer timing logic to the main loop using volatile boolean flags. - ESP32 and RTOS Nuances: On dual-core ESP32 boards running FreeRTOS, the Arduino core maps
millis()to the underlying RTOS tick counter. While generally safe, if you are writing strict multi-core applications, relying on FreeRTOS native timing functions likexTaskGetTickCount()or hardware timer groups is preferred for inter-core synchronization. Furthermore, heavy WiFi/Bluetooth activity on the ESP32 can occasionally introduce slight jitter to the software-mappedmillis()counter compared to a dedicated hardware timer. - Sleep Modes: If your ATmega328P enters deep sleep (e.g.,
Power-downmode), Timer0 is halted. When the MCU wakes via an external interrupt,millis()will resume from where it left off, completely unaware of the hours or days it spent asleep. For ultra-low-power dataloggers, you must track sleep duration via an external Real-Time Clock (RTC) module like the DS3231 and manually offset yourmillis()variables upon waking.
By abandoning the blocking nature of delay() and mastering unsigned subtraction with millis(), you transition from writing simple scripts to engineering robust, production-grade embedded firmware capable of handling the demands of modern electronics.






