The Arduino millis function returns the number of milliseconds since the microcontroller began running its current program. Unlike delay(), which halts all CPU operations, millis() allows your sketch to track time and execute multiple tasks concurrently without blocking the main loop. If you are building a project that requires blinking an LED, reading a DHT22 temperature sensor, and updating an OLED display simultaneously, millis() is the mandatory software timing mechanism.

Direct Answer: Use millis() for any task requiring concurrent execution or UI responsiveness. Reserve delay() strictly for hardware settling times (e.g., a 10ms debounce delay) or initial setup sequences.

The Verdict: Decision Matrix for Timing Methods

Choosing the right timing mechanism depends on your precision requirements and task complexity. Use this decision tree to select the correct approach for your build.

Criteria delay() Arduino millis Function Hardware Timers (e.g., TimerOne)
Blocking? Yes (halts CPU) No (software polling) No (interrupt-driven)
Jitter / Drift High (accumulates) Low (±1ms resolution) None (cycle-accurate)
Complexity Trivial Moderate (state tracking) High (register config)
Best Use Case Sensor settling, setup UI updates, multi-tasking PWM generation, ADC triggering

Concrete Pick: For 90% of hobbyist and IoT sensor nodes, the Arduino millis function is the correct default. Only drop down to hardware timers if you need sub-millisecond precision or are generating high-frequency waveforms.

How the Arduino millis Function Works (And the 50-Day Rollover)

The millis() function reads a hardware timer (Timer0 on the ATmega328P) that increments a 32-bit unsigned integer variable every millisecond. Because it uses an unsigned long data type, the maximum value it can hold is 4,294,967,295. At exactly 49.71 days, this counter overflows and rolls back to zero.

Many beginners write flawed logic that breaks when this rollover occurs. The correct, rollover-safe pattern relies on unsigned subtraction and two's complement arithmetic:

unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;
    // Execute task
}

The Rollover Math Explained

Assume previousMillis is 4,294,967,200 (95ms before rollover) and your interval is 1000ms. When the timer rolls over, currentMillis becomes 50.

  • Flawed logic: if (currentMillis > previousMillis + interval) evaluates to 50 > 4,294,967,200 + 1000. The addition overflows to 904. 50 > 904 is false, but shortly after, currentMillis hits 905 and the condition triggers prematurely, ruining your timing.
  • Correct logic: currentMillis - previousMillis evaluates to 50 - 4,294,967,200. In 32-bit unsigned math, this wraps around to 146. Since 146 >= 1000 is false, the code correctly waits. When currentMillis reaches 946, the subtraction yields 1042, triggering the task exactly 1000ms after the original timestamp.

Hardware & Pin Mapping for a Non-Blocking Multi-Task Build

To demonstrate non-blocking multitasking, we will build a node that blinks an LED, polls a DHT22 sensor every 2 seconds, and updates an I2C OLED display every 500ms. This code specifically targets the Arduino Uno R3 (ATmega328P, 16MHz ceramic resonator). Note that the Uno R3 uses a ceramic resonator rather than a quartz crystal, which introduces a ±0.5% timing drift (roughly 43 seconds per day). For strict timekeeping over months, add an I2C RTC module like the DS3231.

Parts List

  • MCU: Arduino Uno R3 (Rev3) or compatible ATmega328P clone
  • Sensor: DHT22 (AM2302) temperature/humidity sensor
  • Display: 128x64 I2C OLED (SSD1306 driver, 4-pin)
  • Indicator: 5mm Red LED with 220Ω current-limiting resistor
  • Pull-up: 10kΩ resistor for DHT22 data line

Pin Mapping Table

Component MCU Pin Notes
LED Anode D8 220Ω resistor in series
DHT22 Data D4 10kΩ pull-up to 5V required
OLED SDA A4 I2C Data (built-in pull-ups)
OLED SCL A5 I2C Clock

Complete Compilable Code: Non-Blocking Blink and Sensor Read

This sketch requires the DHT sensor library by Adafruit and the Adafruit SSD1306 library. Install both via the Arduino Library Manager. The code includes explicit error handling for sensor timeouts and I2C initialization failures.

#include 
#include 
#include 
#include 

// --- Pin Definitions ---
constexpr uint8_t PIN_LED = 8;
constexpr uint8_t PIN_DHT = 4;
#define DHTTYPE DHT22

// --- Display Dimensions ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C

// --- Task Intervals (ms) ---
constexpr unsigned long INTERVAL_LED = 500;
constexpr unsigned long INTERVAL_SENSOR = 2000;
constexpr unsigned long INTERVAL_DISPLAY = 250;

// --- State Variables ---
unsigned long previousLedMillis = 0;
unsigned long previousSensorMillis = 0;
unsigned long previousDisplayMillis = 0;
bool ledState = false;
float lastTemp = 0.0;
float lastHum = 0.0;

DHT dht(PIN_DHT, DHTTYPE);
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

void setup() {
    Serial.begin(115200);
    pinMode(PIN_LED, OUTPUT);
    dht.begin();

    if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
        Serial.println(F("SSD1306 allocation failed"));
        for(;;); // Halt execution on critical I2C failure
    }
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);
}

void loop() {
    unsigned long currentMillis = millis();

    // Task 1: Non-blocking LED Blink
    if (currentMillis - previousLedMillis >= INTERVAL_LED) {
        previousLedMillis = currentMillis;
        ledState = !ledState;
        digitalWrite(PIN_LED, ledState);
    }

    // Task 2: Non-blocking Sensor Polling
    if (currentMillis - previousSensorMillis >= INTERVAL_SENSOR) {
        previousSensorMillis = currentMillis;
        float t = dht.readTemperature();
        float h = dht.readHumidity();

        // Error handling for DHT22 timeout/checksum failure
        if (isnan(t) || isnan(h)) {
            Serial.println(F("Failed to read from DHT sensor!"));
        } else {
            lastTemp = t;
            lastHum = h;
        }
    }

    // Task 3: Non-blocking Display Update
    if (currentMillis - previousDisplayMillis >= INTERVAL_DISPLAY) {
        previousDisplayMillis = currentMillis;
        display.clearDisplay();
        display.setCursor(0,0);
        display.print(F("Temp: ")); display.print(lastTemp); display.println(F(" C"));
        display.print(F("Hum:  ")); display.print(lastHum); display.println(F(" %"));
        display.display();
    }
}

Debugging: First Three Things to Check When Timing Fails

When your non-blocking code misbehaves, it is rarely a flaw in the millis() function itself. Check these three ranked failure modes first.

1. Symptom: Timer stops after ~32 seconds

  • Exact Error String: Compiler warning warning: overflow in implicit constant conversion [-Woverflow] or silent runtime failure.
  • Cause: You declared your interval or timestamp variables as int instead of unsigned long. A standard 16-bit signed int on the AVR architecture maxes out at 32,767. At 32.7 seconds, it overflows into negative numbers, breaking the subtraction logic.
  • Fix: Change all timing variables to unsigned long or uint32_t.

2. Symptom: Tasks lock up or rapid-fire after 49.7 days

  • Exact Error String: No compiler error; runtime logic failure.
  • Cause: You used addition instead of subtraction in your conditional: if (currentMillis > previousMillis + interval). When previousMillis + interval exceeds 4,294,967,295, it rolls over to a small number, causing the condition to evaluate true immediately and continuously.
  • Fix: Always use the subtraction pattern: if (currentMillis - previousMillis >= interval).

3. Symptom: Timing drifts by seconds over a few hours

  • Exact Error String: N/A (Hardware limitation).
  • Cause: The Arduino Uno R3 uses a Murata ceramic resonator for its 16MHz clock, which has a ±0.5% tolerance. Furthermore, calling blocking functions like Serial.print() at low baud rates inside your loop adds microsecond delays that accumulate over time.
  • Fix: Increase serial baud rate to 115200. For absolute timekeeping, sync millis() against a DS3231 RTC module once per hour.

Extending and Simplifying Your Build

Managing five or six previousMillis variables and if statements quickly turns the loop() function into an unreadable mess. If your project requires more than three concurrent timed tasks, abandon manual millis() tracking and use a cooperative multitasking library.

The definitive choice is the TaskScheduler library by Arkhipenko. It abstracts the millis() math into callback functions, handles dynamic interval changes, and manages task dependencies.

When to avoid TaskScheduler: If you are programming an ESP32 or Raspberry Pi Pico, you have access to true FreeRTOS multicore capabilities. On those boards, use xTaskCreatePinnedToCore() or hardware timer interrupts instead of a software polling library. TaskScheduler is specifically optimized for single-core, single-threaded AVR boards like the Uno and Nano.

By mastering the Arduino millis function and understanding its underlying 32-bit unsigned arithmetic, you eliminate the most common bottleneck in embedded systems: blocking code. Stick to the subtraction pattern, use unsigned long for all timestamps, and upgrade to a scheduler library when your state machine outgrows manual tracking.