Why the Arduino Function millis() Beats delay() Every Time
The millis() function returns the number of milliseconds elapsed since the microcontroller began running the current program. Unlike delay(), which halts the CPU and blinds your board to button presses or incoming serial data, millis() enables true cooperative multitasking. By storing a previous timestamp and comparing it to the current timestamp, you can trigger events at precise intervals while the rest of your loop() continues to execute.
current - previous >= interval). Never use addition (current >= previous + interval). Subtraction naturally handles the 49.7-day unsigned long rollover; addition will cause catastrophic logic failures when the counter wraps around to zero.
In this guide, we will build a non-blocking environmental logger that blinks status LEDs and reads a BME280 sensor concurrently. We will also dissect the exact compiler warnings and runtime hangs that occur when millis() is implemented incorrectly.
Project Build: Non-Blocking Sensor Logger & Status Beacon
To demonstrate concurrent task execution, we need a setup where tasks operate on completely different timescales. A 500ms LED heartbeat, a 1000ms serial telemetry stream, and a 5000ms slow I2C sensor read cannot be cleanly orchestrated using delay() without writing a massive, brittle state machine.
Parts List
- Microcontroller: Arduino Nano V3.0 (ATmega328P, 16MHz, 5V logic)
- Sensor: Adafruit BME280 I2C Breakout (Product ID: 2652)
- Indicators: 2x 5mm LEDs (1x Red, 1x Green)
- Current Limiting: 2x 330Ω through-hole resistors (1/4W)
- Prototyping: 400-point solderless breadboard, 22 AWG solid core jumper wires
Pin Mapping & Wiring Table
| Arduino Nano Pin | Component | Wire Color (Suggested) | Notes |
|---|---|---|---|
| 5V | BME280 VIN | Red | BME280 breakout has onboard 3.3V regulator |
| GND | BME280 GND, LED Cathodes | Black | Common ground rail |
| A4 (SDA) | BME280 SDA | Blue | I2C Data (Nano internal pull-ups enabled) |
| A5 (SCL) | BME280 SCL | Yellow | I2C Clock |
| D4 | Red LED Anode (via 330Ω) | Orange | Heartbeat indicator (500ms toggle) |
| D5 | Green LED Anode (via 330Ω) | Green | Sensor read indicator (5000ms pulse) |
The Complete Non-Blocking Code (Arduino Nano V3)
This code targets the Arduino Nano V3.0 (ATmega328P). It uses the Adafruit BME280 library. Ensure you have installed both Adafruit BME280 Library and Adafruit Unified Sensor via the Library Manager before compiling.
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// --- PIN DEFINITIONS ---
const int PIN_LED_HEARTBEAT = 4;
const int PIN_LED_SENSOR = 5;
// --- TASK INTERVALS (milliseconds) ---
const unsigned long INTERVAL_HEARTBEAT = 500;
const unsigned long INTERVAL_SERIAL = 1000;
const unsigned long INTERVAL_SENSOR = 5000;
// --- TIMING VARIABLES (MUST be unsigned long) ---
unsigned long previousMillisHeartbeat = 0;
unsigned long previousMillisSerial = 0;
unsigned long previousMillisSensor = 0;
// --- STATE VARIABLES ---
bool heartbeatState = false;
float lastTempC = 0.0;
float lastHumidity = 0.0;
bool sensorReady = false;
Adafruit_BME280 bme;
void setup() {
Serial.begin(115200);
pinMode(PIN_LED_HEARTBEAT, OUTPUT);
pinMode(PIN_LED_SENSOR, OUTPUT);
// Non-blocking I2C initialization check
if (!bme.begin(0x76)) {
Serial.println(F("BME280 not found. Check wiring!"));
// Blink both LEDs rapidly to indicate hardware fault without blocking loop
while(1) {
digitalWrite(PIN_LED_HEARTBEAT, HIGH);
digitalWrite(PIN_LED_SENSOR, HIGH);
delay(100); // delay is acceptable ONLY in a fatal halt state
digitalWrite(PIN_LED_HEARTBEAT, LOW);
digitalWrite(PIN_LED_SENSOR, LOW);
delay(100);
}
}
sensorReady = true;
Serial.println(F("System Online. Multitasking active."));
}
void loop() {
unsigned long currentMillis = millis();
// TASK 1: 500ms Heartbeat LED
if (currentMillis - previousMillisHeartbeat >= INTERVAL_HEARTBEAT) {
previousMillisHeartbeat = currentMillis;
heartbeatState = !heartbeatState;
digitalWrite(PIN_LED_HEARTBEAT, heartbeatState);
}
// TASK 2: 5000ms Sensor Read
if (currentMillis - previousMillisSensor >= INTERVAL_SENSOR) {
previousMillisSensor = currentMillis;
if (sensorReady) {
lastTempC = bme.readTemperature();
lastHumidity = bme.readHumidity();
// Pulse green LED to show sensor read occurred
digitalWrite(PIN_LED_SENSOR, HIGH);
// Note: We don't block to turn it off; we'll handle it in the serial task or next loop
}
}
// TASK 3: 1000ms Serial Telemetry & LED reset
if (currentMillis - previousMillisSerial >= INTERVAL_SERIAL) {
previousMillisSerial = currentMillis;
Serial.print(F("Temp: ")); Serial.print(lastTempC);
Serial.print(F(" C | Hum: ")); Serial.print(lastHumidity);
Serial.println(F(" %"));
// Turn off sensor LED after serial print
digitalWrite(PIN_LED_SENSOR, LOW);
}
}
Debugging: First Three Things to Check When millis() Fails
When a millis() implementation fails, it rarely crashes the board. Instead, tasks silently stop executing, or the compiler throws warnings that beginners ignore. If your non-blocking timers freeze, check these three items immediately.
1. Verify Data Types (The 16-Bit Integer Trap)
The most common failure on 8-bit AVR boards (Uno/Nano/Mega) is declaring timer variables as int. An int on these boards is 16 bits, maxing out at 32,767. Your timer will fail after just 32.7 seconds. Furthermore, the compiler will flag this with an exact error string:
warning: comparison is always false due to limited range of data type [-Wtype-limits]
Fix: Change all time-tracking variables (currentMillis, previousMillis, and interval) to unsigned long.
2. Check for Addition vs. Subtraction Logic
If you write if (currentMillis >= previousMillis + interval), your code will work perfectly for 49 days. On day 49.7, currentMillis rolls over to 0. previousMillis + interval remains a massive number near 4.2 billion. Zero is not greater than 4.2 billion, so the task permanently hangs.
Fix: Always subtract the past from the present: if (currentMillis - previousMillis >= interval). Unsigned math in C++ naturally wraps around, yielding the correct elapsed time even across the rollover boundary.
3. Eliminate Exact Equality Checks
Beginners often write if (currentMillis - previousMillis == interval). If your loop takes 2ms to execute because of an I2C read or a Serial.print() buffer flush, currentMillis might jump from 499 to 501. The condition == 500 is skipped entirely, and the timer never resets.
Fix: Always use the greater-than-or-equal-to operator (>=).
loop() contains heavy processing that starves the Wi-Fi/Bluetooth stack, the Task Watchdog Timer (WDT) will trigger a reset. Using millis() to chunk large tasks into smaller loop iterations prevents Task watchdog got triggered panics.
Extending and Simplifying the Build
Managing five or six previousMillis variables in a single loop() creates spaghetti code. Here is how to scale your architecture:
- Simplify with Libraries: For standard Arduino boards, the Metro library abstracts the subtraction math into clean
metro.check()calls. For ESP32 boards, use the nativeTickerlibrary to attach hardware interrupts to timer callbacks, completely removing timing logic from the main loop. - Extend with State Machines: If your sensor read requires a multi-step handshake (e.g., triggering a heater, waiting 2 seconds, reading, cooling down), combine
millis()with anenumstate machine. Each state tracks its ownpreviousMillis, preventing the main loop from becoming cluttered with nestedifstatements. - Hardware Timers: If you need microsecond precision or hardware-level PWM generation independent of the CPU, bypass
millis()and configure the ATmega328P's 8-bit or 16-bit hardware timers via direct register manipulation (TCCR1A, OCR1A).
Frequently Asked Questions (FAQ)
How do I reset the Arduino millis() function to zero?
You cannot manually reset the internal hardware counter that drives millis() without modifying core Arduino wiring files or triggering a hardware reset (which reboots the board). Instead, reset your relative timer by setting your previousMillis variable equal to the current millis() value. This effectively establishes a new 'zero' point for your specific task without touching the system clock.
What is the maximum value of the millis() function before rollover?
The function returns an unsigned long, which is a 32-bit unsigned integer. The maximum value is 4,294,967,295 milliseconds. This equates to exactly 49 days, 17 hours, 2 minutes, and 47 seconds of continuous uptime. When it hits this ceiling, it silently rolls over to 0 and begins counting up again. As noted in the debugging section, proper subtraction math handles this rollover transparently.
Can I use the Arduino millis() function for microsecond timing?
No. millis() updates roughly every 1 millisecond (1024µs on standard AVRs due to prescaler math). For microsecond resolution, use the micros() function. Be aware that micros() also returns an unsigned long, but because it counts a million units per second, it rolls over much faster—approximately every 71.5 minutes. The same subtraction rule (currentMicros - previousMicros >= interval) applies to prevent rollover bugs.
Why does my millis() timer drift over time?
The millis() counter is driven by the board's primary crystal oscillator or ceramic resonator. Standard Arduino clones use cheap ceramic resonators that can drift by 0.5% to 1% depending on ambient temperature. Over 24 hours, a 1% drift results in an 864-second (14.4 minute) error. If you are building a wall clock or a precision data logger, you must either use a board with a Temperature Compensated Crystal Oscillator (TCXO), or sync the time periodically via an NTP server (ESP32) or a dedicated RTC module like the DS3231.






