Why delay() Fails and How millis() in Arduino Fixes It
When you first start programming microcontrollers, the delay() function is a crutch. It pauses the entire processor, freezing all outputs and ignoring all inputs until the timer expires. Think of delay() like staring at a microwave countdown: you cannot answer the phone, chop vegetables, or do anything else until the timer hits zero. In embedded systems, this blocking behavior is fatal for responsive applications.
The millis() function in Arduino solves this by acting as a stopwatch. It returns the number of milliseconds since the board began running the current program as an unsigned long integer. Instead of pausing the processor, you record a timestamp, perform other tasks, and periodically check if enough time has passed. This allows your Arduino to blink an LED, read a sensor, and listen for serial commands simultaneously.
According to the official Arduino millis() reference, the function relies on Timer0, which increments every 1024 clock cycles. While this provides a reliable 1-millisecond resolution for general timing, it introduces a notorious edge case: the 49.7-day rollover. Because an unsigned long maxes out at 4,294,967,295, the counter resets to zero roughly every 49.7 days. If your code handles this math incorrectly, your project will catastrophically fail right at the 49-day mark.
Project Build: Non-Blocking Multi-Tasking Blink & Sensor Read
Target Board Variant: Arduino Uno R3 (ATmega328P) or Arduino Nano v3 (ATmega328P). The code is fully compatible with ESP32 and Mega2560 without modification.
Parts List & Pin Mapping
| Component | Exact Variant / Value | Arduino Pin | Notes |
|---|---|---|---|
| Microcontroller | Arduino Uno R3 (ATmega328P) | N/A | 5V logic, 16MHz clock |
| Temperature Sensor | DHT22 (AM2302 module) | D4 | Includes onboard 10kΩ pull-up |
| Status LED | 5mm Red LED | D8 | Current limited via resistor |
| Current Limiter | 220Ω Resistor (1/4W) | Series with D8 | Drops ~2V at 15mA |
| Heartbeat LED | 5mm Green LED | D13 (Built-in) | Indicates main loop is unblocked |
Complete Compilable Code
This sketch demonstrates the correct implementation of millis() for two independent intervals. It includes robust error handling for the DHT22 sensor, ensuring that a read failure does not crash the timing logic. We use the Adafruit DHT library for reliable sensor parsing.
#include <DHT.h>
// --- Pin Definitions ---
const int DHT_PIN = 4;
const int LED_TASK_PIN = 8;
const int HEARTBEAT_PIN = 13;
// --- Sensor Setup ---
#define DHTTYPE DHT22
DHT dht(DHT_PIN, DHTTYPE);
// --- Timing Variables (MUST be unsigned long) ---
unsigned long previousMillisLED = 0;
unsigned long previousMillisSensor = 0;
// --- Interval Constants ---
const unsigned long INTERVAL_LED = 500; // 500ms blink
const unsigned long INTERVAL_SENSOR = 2000; // 2000ms read
// --- State Variables ---
int ledState = LOW;
void setup() {
Serial.begin(115200);
pinMode(LED_TASK_PIN, OUTPUT);
pinMode(HEARTBEAT_PIN, OUTPUT);
dht.begin();
Serial.println("System Initialized. Non-blocking loop active.");
}
void loop() {
// Heartbeat toggle to prove the loop is never blocked
digitalWrite(HEARTBEAT_PIN, !digitalRead(HEARTBEAT_PIN));
unsigned long currentMillis = millis();
// --- Task 1: Blink LED every 500ms ---
if (currentMillis - previousMillisLED >= INTERVAL_LED) {
previousMillisLED = currentMillis; // CRITICAL: Save the current time, do not set to 0
ledState = !ledState;
digitalWrite(LED_TASK_PIN, ledState);
}
// --- Task 2: Read Sensor every 2000ms ---
if (currentMillis - previousMillisSensor >= INTERVAL_SENSOR) {
previousMillisSensor = currentMillis;
float humidity = dht.readHumidity();
float tempC = dht.readTemperature();
// Error Handling: Check if any reads failed (NaN)
if (isnan(humidity) || isnan(tempC)) {
Serial.println("ERROR: Failed to read from DHT22 sensor! Check wiring.");
} else {
Serial.print("Temp: ");
Serial.print(tempC);
Serial.print(" C | Humidity: ");
Serial.print(humidity);
Serial.println(" %");
}
}
}
Debugging the 49-Day Rollover Bug (And Other millis() Failures)
The most infamous failure mode in Arduino timing is the 49.7-day rollover. When millis() exceeds 4,294,967,295, it overflows and resets to 0. If your code uses absolute time comparisons (e.g., if (currentMillis > triggerTime)) instead of relative time math, your system will hang or behave erratically.
When developers attempt to fix this and fail, they often introduce subtraction order errors, resulting in a massive negative number that wraps around in unsigned math. If you implement a debug wrapper to catch this, your serial monitor will output an exact error string like this:
FATAL: Negative time delta detected (-4294967290). Check millis() subtraction order.
Ranked Causes of millis() Timing Failures
- Reversing the Subtraction Order: Writing
previousMillis - currentMillisinstead ofcurrentMillis - previousMillis. Unsigned math makes the former evaluate to a massive number (~4.29 billion) when the rollover occurs, triggering immediate false-positive interval completions. - Using Signed or 16-bit Integers: Declaring your time variables as
intorlong. Aninton the ATmega328P is only 16 bits and will roll over every 32.7 seconds. Alongis signed and will roll over into negative numbers at 24.8 days, breaking your >= comparison logic. - Resetting to Zero Instead of Current Time: Writing
previousMillis = 0;instead ofpreviousMillis = currentMillis;. This causes the interval to compound and drift heavily over time, especially right after a rollover event.
The First Three Things to Check When Timing Fails
- Verify Variable Types: Ensure every single variable interacting with
millis()(current, previous, and interval) is explicitly declared asunsigned long. - Check the Subtraction Syntax: It must always be
(Current - Previous) >= Interval. Never use(Current > Previous + Interval), as the addition on the right side will overflow and fail. - Inspect Interval Magnitude: Ensure your interval constant does not exceed 4,294,967,295. If you need delays longer than 49 days, you must implement a secondary counter or use a Real Time Clock (RTC) module.
The beauty of the correct syntax (currentMillis - previousMillis >= interval) is that it inherently survives the rollover. In 32-bit unsigned math, subtracting a large pre-rollover number from a small post-rollover number automatically wraps around to yield the correct positive elapsed time. For example, 0x00000005 - 0xFFFFFFFA = 0x0000000B (11 milliseconds elapsed).
Frequently Asked Questions About millis() in Arduino
Does millis() in Arduino lose accuracy over time?
Yes, slightly. The millis() function relies on the board's main clock source. Standard Arduino Uno R3 boards use a 16MHz ceramic resonator, which can drift by up to 0.5% depending on ambient temperature. This translates to a drift of roughly 7 minutes per day. If your project requires precise long-term timekeeping (like a data logger timestamping events), millis() is insufficient. You must use an external I2C Real Time Clock (RTC) like the DS3231, which uses a temperature-compensated crystal oscillator (TCXO) accurate to within a few seconds per month. For short-term task scheduling (under a few hours), the ceramic resonator drift is negligible.
Can I use millis() inside an Interrupt Service Routine (ISR)?
No, you should never call millis() or attempt to update millis()-dependent variables inside an ISR. The millis() counter itself is updated by the Timer0 overflow interrupt. If your custom ISR fires while the Timer0 ISR is executing, or if you spend too much time inside your ISR, you will block the system timer, causing millis() to freeze or lose ticks. According to the Arduino attachInterrupt() documentation, ISRs should be as fast as possible. If you need to timestamp an event inside an ISR, read the micros() function or directly read the hardware timer registers, and pass that raw value to your main loop for processing.
How do I extend millis() to track days or months?
You cannot extend the native millis() function, as it is hardcoded in the Arduino core to return a 32-bit unsigned integer. However, you can build a software wrapper in your sketch. Create a global unsigned long dayCounter and a bool rolloverDetected flag. In your main loop, check if currentMillis < previousMillis (which only happens during the 49-day rollover). When detected, increment your dayCounter. For tracking calendar months or years, software counting is highly error-prone due to leap years and daylight saving time. Simplify your build by abandoning millis() for calendar math and integrating a DS3231 RTC module via the RTClib library.
What is the difference between millis() and micros()?
While millis() returns milliseconds (1/1,000th of a second) and rolls over every 49.7 days, micros() returns microseconds (1/1,000,000th of a second) and rolls over every 70 minutes. Furthermore, on a standard 16MHz Arduino Uno, micros() does not have a true 1-microsecond resolution; it updates in steps of 4 microseconds due to the timer prescaler configuration. Use millis() for human-scale interactions (blinking LEDs, debouncing buttons, sensor polling) and reserve micros() strictly for high-speed signal measurement, such as calculating pulse widths for RC servos or ultrasonic distance sensors.






