If you have ever left an Arduino sensor node running over a long weekend only to find it completely frozen on Monday morning, you have likely met the millis() rollover bug. The millis() function is the backbone of non-blocking timing in the Arduino ecosystem, returning the number of milliseconds since the microcontroller booted. However, replacing delay() with millis() requires a fundamental shift in how you structure your loop().
The direct answer to writing robust, non-blocking timers is to subtract a stored previous timestamp from the current millis() value, rather than checking if millis() has crossed a future target threshold. This subtraction method inherently handles the 49.7-day integer overflow without requiring complex reset logic. Below, we break down the exact math, build a multi-tasking sensor node on an Arduino Nano V3, and debug the most common failure modes.
The Math Behind millis() and the Rollover Trap
The millis() function returns an unsigned long (a 32-bit unsigned integer on standard AVR boards). The maximum value of a 32-bit unsigned integer is 4,294,967,295. Once the internal timer hits this value, it overflows and resets to zero. At exactly 49.71 days of continuous uptime, your timer rolls over.
Beginners often write blocking or rollover-vulnerable code by calculating a future target time. The correct approach uses interval subtraction. Review the data types and their limits in the table below before writing your timing logic.
| Data Type | Bit Width | Max Value | Time Equivalent | Rollover Behavior |
|---|---|---|---|---|
unsigned long | 32-bit | 4,294,967,295 | 49.71 Days | Standard millis() return type. Safe for subtraction. |
uint32_t | 32-bit | 4,294,967,295 | 49.71 Days | Explicit C++ standard type. Preferred for portability. |
unsigned int | 16-bit (AVR) | 65,535 | 65.5 Seconds | Will cause silent failures if interval > 65s on ATmega328P. |
int | 16-bit (AVR) | 32,767 | 32.7 Seconds | Disaster. Turns negative after 32s, breaking all logic. |
unsigned long or uint32_t for both your previousMillis variables and your interval constants. If you define your interval as a standard int, the compiler will promote it during comparison, but you risk silent truncation if your interval exceeds 32,767 milliseconds (32.7 seconds).According to the official Arduino Reference, the subtraction method if (currentMillis - previousMillis >= interval) works flawlessly during rollover because unsigned integer math wraps around predictably. If currentMillis is 10 (just after rollover) and previousMillis is 4,294,967,200 (just before rollover), the subtraction yields 105, accurately reflecting the elapsed time.
Project Build: Non-Blocking Multi-Task Sensor Node
To demonstrate this in practice, we will build a multi-tasking node that blinks two LEDs at different rates while simultaneously polling a BME280 environmental sensor over I2C. If you used delay() for the slow LED, the fast LED and sensor readings would stutter. With millis(), all tasks run concurrently.
Parts List
- Microcontroller: Arduino Nano V3 (ATmega328P, 16MHz)
- Sensor: BME280 I2C Breakout (Adafruit 2652 or generic equivalent)
- Indicators: 2x 5mm LEDs (Red and Green)
- Current Limiting: 2x 220Ω through-hole resistors
- Wiring: Breadboard and male-to-male jumper wires
Pin Mapping Table
| Nano V3 Pin | Component | Function |
|---|---|---|
| A4 (SDA) | BME280 SDA | I2C Data Line |
| A5 (SCL) | BME280 SCL | I2C Clock Line |
| D4 | LED 1 (Red) Anode | Fast Task Indicator (100ms) |
| D5 | LED 2 (Green) Anode | Slow Task Indicator (1000ms) |
| 3V3 | BME280 VIN | Power (3.3V logic) |
| GND | Common Ground | System Ground |
Complete Compilable Code
This code targets the Arduino Nano V3. Ensure you have the Adafruit BME280 Library installed via the Library Manager before compiling.
#include <Wire.h>
#include <Adafruit_BME280.h>
// Pin definitions - never hardcode pins in the loop
#define PIN_LED_FAST 4
#define PIN_LED_SLOW 5
#define SEALEVELPRESSURE_HPA (1013.25)
Adafruit_BME280 bme;
// Timing variables MUST be unsigned long to prevent rollover math errors
unsigned long previousMillisFast = 0;
unsigned long previousMillisSlow = 0;
unsigned long previousMillisSensor = 0;
// Interval constants MUST be unsigned long
const unsigned long intervalFast = 100; // 100ms
const unsigned long intervalSlow = 1000; // 1 second
const unsigned long intervalSensor = 5000; // 5 seconds
void setup() {
Serial.begin(115200);
pinMode(PIN_LED_FAST, OUTPUT);
pinMode(PIN_LED_SLOW, OUTPUT);
// Error handling: Halt if sensor is missing or wired incorrectly
if (!bme.begin(0x76)) {
Serial.println(F('ERROR: Could not find a valid BME280 sensor, check I2C wiring!'));
while (1) {
// Blink both LEDs rapidly to indicate hardware fault
digitalWrite(PIN_LED_FAST, HIGH);
digitalWrite(PIN_LED_SLOW, HIGH);
delay(50);
digitalWrite(PIN_LED_FAST, LOW);
digitalWrite(PIN_LED_SLOW, LOW);
delay(50);
}
}
Serial.println(F('BME280 initialized. Starting non-blocking tasks.'));
}
void loop() {
unsigned long currentMillis = millis();
// Task 1: Fast LED Toggle
if (currentMillis - previousMillisFast >= intervalFast) {
previousMillisFast = currentMillis;
digitalWrite(PIN_LED_FAST, !digitalRead(PIN_LED_FAST));
}
// Task 2: Slow LED Toggle
if (currentMillis - previousMillisSlow >= intervalSlow) {
previousMillisSlow = currentMillis;
digitalWrite(PIN_LED_SLOW, !digitalRead(PIN_LED_SLOW));
}
// Task 3: Sensor Polling and Telemetry
if (currentMillis - previousMillisSensor >= intervalSensor) {
previousMillisSensor = currentMillis;
float temp = bme.readTemperature();
float humidity = bme.readHumidity();
Serial.print('Temp: ');
Serial.print(temp);
Serial.print(' C | Humidity: ');
Serial.print(humidity);
Serial.println(' %');
}
}Debugging millis(): The First Three Things to Check
When your non-blocking code compiles but tasks drift, freeze, or behave erratically after hours of runtime, the issue is almost always in how the timing variables are declared or how the loop() is structured. Here are the first three things to check when it fails.
1. Signed vs. Unsigned Integer Mismatch
If you accidentally declare your interval or previous timestamp as a signed int or long, the GCC compiler will throw a specific warning. Do not ignore this warning.
Exact Error String:warning: comparison between signed and unsigned integer expressions [-Wsign-compare]
The Fix: Change all timing variables to unsigned long. When a signed integer reaches its maximum positive value (2,147,483,647 for a 32-bit signed long), it wraps to a negative number (-2,147,483,648). Your if condition will immediately evaluate to true or false incorrectly, causing tasks to either fire continuously or never fire again.
2. Accidental Blocking Inside the If-Block
The millis() subtraction only guarantees that the if block is entered on time. It does not protect you from blocking the processor once inside. If you call delay(100), Serial.flush(), or use a blocking sensor library (like older DHT11 libraries that disable interrupts for 20ms) inside your if statement, you will starve your other tasks.
The Fix: Audit the contents of every if (currentMillis - previousMillis >= interval) block. Ensure no function inside takes longer than a few microseconds to execute. If you must read a slow sensor, use an asynchronous library or a state machine that breaks the read into multiple loop() passes.
3. Updating previousMillis Incorrectly
A common logic error is setting previousMillis = currentMillis at the beginning of the loop() instead of inside the if block, or updating it when the task actually finishes rather than when it triggers.
The Fix: Always update the timestamp immediately upon entering the if block: previousMillis = currentMillis;. If your task takes 5ms to execute, and you update previousMillis at the end of the task, your interval will slowly drift by 5ms every cycle, compounding into massive timing errors over a 24-hour period.
Extending and Simplifying the Architecture
As your project grows from three tasks to fifteen, managing a dozen previousMillis variables and if statements in the main loop() becomes unmaintainable. Here is how to extend and simplify the build based on your complexity needs.
When to Simplify: Use a Task Scheduler
If you find yourself copy-pasting the millis() subtraction boilerplate more than five times, switch to a cooperative multitasking library. The TaskScheduler library by Arkhipenko abstracts the millis() math into callback functions. You define a task, assign it an interval, and the library handles the non-blocking execution and rollover math under the hood. This drastically reduces code size and eliminates variable-naming collisions.
When to Extend: Microsecond Precision and Watchdogs
For applications requiring sub-millisecond precision (like custom PWM generation or ultrasonic distance measurement), millis() lacks the resolution. Extend your architecture by swapping to micros(). The exact same subtraction logic applies, but be aware that micros() overflows every 70 minutes instead of 49.7 days.
Finally, for remote deployments where a hard lockup is unacceptable, extend your build by enabling the AVR Watchdog Timer (WDT). If a blocking I2C fault freezes your loop() and prevents millis() from being read, the WDT will automatically hardware-reset the Nano V3, ensuring your node recovers without manual intervention.






