The delay() function is the first timing tool every maker learns, but it is also the first one you must abandon when building real-world embedded systems. Because delay() halts the microcontroller's CPU, your board cannot read sensors, debounce buttons, or handle serial communication while waiting. The solution is arduino millis, a core function that returns the number of milliseconds since the board booted, enabling non-blocking, concurrent task execution.

This guide walks through building a non-blocking multi-relay timer for greenhouse automation, provides production-ready C++ code, and dissects the infamous 49.7-day rollover bug that breaks poorly written timers in long-running deployments.

The Non-Blocking Multi-Relay Timer (Project Overview & Parts)

Difficulty: Intermediate | Time: 45 Minutes | Cost: ~$22 USD

Our target project is a dual-channel environmental controller. Channel 1 triggers a 12V water pump for 5 seconds every 2 hours. Channel 2 triggers a ventilation fan for 10 seconds every 30 minutes. Both tasks must run independently without blocking a manual override button on pin D2.

Required Parts List

  • Microcontroller: Arduino Uno R3 (ATmega328P) or genuine Nano v3 (ATmega328P). Note: The code targets the AVR 8-bit architecture where unsigned long is exactly 32 bits.
  • Relay Module: 4-Channel 5V Relay Module (Opto-isolated, Active-LOW logic). Look for modules with built-in flyback diodes and PC817 optocouplers to protect the ATmega328P GPIO pins from inductive kickback.
  • Switch: Momentary pushbutton (normally open) for manual override.
  • Power: 12V DC 2A power supply (for the pump/fan loads) and a standard 5V USB supply for the Arduino.
  • Wiring: 22 AWG solid core wire for breadboard/terminal connections.

Pin Mapping & Hardware Wiring

Before uploading code, verify your physical connections against this mapping table. Active-LOW relay modules require the GPIO pin to sink current to ground to energize the coil.

Component Arduino Pin Logic State Wiring Notes
Relay 1 (Pump) D8 Active-LOW Connect JD-VCC to 5V, VCC to 5V, GND to GND. Remove VCC/JD-VCC jumper for true isolation.
Relay 2 (Fan) D9 Active-LOW Same as above.
Override Button D2 Active-LOW (Internal Pull-up) Connect one leg to D2, the other to GND. No external resistor needed.
Wiring Tip: Never power high-current inductive loads (like a 12V pump) directly from the Arduino's 5V rail. The ATmega328P can only source/sink 20mA per pin (40mA absolute max). Always use the relay module as a galvanic switch.

Complete Non-Blocking Code (Targeting Arduino Uno R3)

The following sketch is fully compilable and includes explicit pin definitions, state tracking, and a serial debug trap for the rollover bug. It targets the Arduino Uno R3 and Nano v3 (ATmega328P). If you are porting this to an ESP32, note that ESP32 uses a 64-bit unsigned long long for millis() in newer Arduino cores, which fundamentally changes the rollover math.

#include <Arduino.h>

// --- PIN DEFINITIONS ---
#define PIN_RELAY_PUMP   8
#define PIN_RELAY_FAN    9
#define PIN_BTN_OVERRIDE 2

// --- TIMING CONSTANTS (in milliseconds) ---
#define PUMP_INTERVAL    7200000UL  // 2 hours (7,200,000 ms)
#define PUMP_RUNTIME     5000UL     // 5 seconds
#define FAN_INTERVAL     1800000UL  // 30 minutes (1,800,000 ms)
#define FAN_RUNTIME      10000UL    // 10 seconds
#define DEBOUNCE_DELAY   50UL       // 50 ms button debounce

// --- STATE VARIABLES ---
unsigned long previousMillisPump = 0;
unsigned long previousMillisFan = 0;
unsigned long previousMillisBtn = 0;

bool pumpState = false; // false = OFF, true = ON
bool fanState = false;

void setup() {
  Serial.begin(115200);
  Serial.println(F("System Boot: Non-blocking timer initialized."));

  pinMode(PIN_RELAY_PUMP, OUTPUT);
  pinMode(PIN_RELAY_FAN, OUTPUT);
  pinMode(PIN_BTN_OVERRIDE, INPUT_PULLUP);

  // Ensure relays start in the OFF state (Active-LOW means HIGH = OFF)
  digitalWrite(PIN_RELAY_PUMP, HIGH);
  digitalWrite(PIN_RELAY_FAN, HIGH);

  // Initialize timers to current millis to prevent immediate firing on boot
  unsigned long bootTime = millis();
  previousMillisPump = bootTime;
  previousMillisFan = bootTime;
}

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

  // --- ROLLOVER BUG TRAP (Debugging Aid) ---
  // If a beginner accidentally casts to signed int, this catches it.
  if ((long)(currentMillis - previousMillisPump) < 0) {
    Serial.println(F("ERROR: Timer stalled - negative delta calculated"));
    Serial.println(F("Fix: Ensure all time variables are 'unsigned long'"));
    while(1); // Halt execution to prevent hardware damage
  }

  // --- PUMP LOGIC ---
  if (!pumpState && (currentMillis - previousMillisPump >= PUMP_INTERVAL)) {
    // Time to turn ON
    pumpState = true;
    previousMillisPump = currentMillis;
    digitalWrite(PIN_RELAY_PUMP, LOW); // Active LOW
    Serial.println(F("Pump: ON"));
  } else if (pumpState && (currentMillis - previousMillisPump >= PUMP_RUNTIME)) {
    // Time to turn OFF
    pumpState = false;
    previousMillisPump = currentMillis;
    digitalWrite(PIN_RELAY_PUMP, HIGH);
    Serial.println(F("Pump: OFF"));
  }

  // --- FAN LOGIC ---
  if (!fanState && (currentMillis - previousMillisFan >= FAN_INTERVAL)) {
    fanState = true;
    previousMillisFan = currentMillis;
    digitalWrite(PIN_RELAY_FAN, LOW);
    Serial.println(F("Fan: ON"));
  } else if (fanState && (currentMillis - previousMillisFan >= FAN_RUNTIME)) {
    fanState = false;
    previousMillisFan = currentMillis;
    digitalWrite(PIN_RELAY_FAN, HIGH);
    Serial.println(F("Fan: OFF"));
  }

  // --- NON-BLOCKING BUTTON DEBOUNCE ---
  if (digitalRead(PIN_BTN_OVERRIDE) == LOW) {
    if (currentMillis - previousMillisBtn >= DEBOUNCE_DELAY) {
      previousMillisBtn = currentMillis;
      // Toggle fan manually
      fanState = !fanState;
      digitalWrite(PIN_RELAY_FAN, fanState ? LOW : HIGH);
      Serial.print(F("Manual Override - Fan: "));
      Serial.println(fanState ? F("ON") : F("OFF"));
    }
  }
}

Debugging the "Millis Rollover" Bug

The most notorious issue in long-running Arduino projects is the 49.7-day rollover. The millis() function returns an unsigned long (a 32-bit unsigned integer). The maximum value of a 32-bit integer is 4,294,967,295. At exactly 49 days, 17 hours, 2 minutes, and 47 seconds, the counter overflows and resets to 0.

If your code uses the wrong math or data types, your system will hang or behave erratically at this exact moment. If you see the serial monitor outputting "ERROR: Timer stalled - negative delta calculated", or your hardware simply stops responding after roughly a month and a half, check these three things immediately:

1. Verify Data Types (The #1 Culprit)

Every variable involved in timing must be declared as unsigned long. If you use int (which is a signed 16-bit integer on AVR boards, maxing out at 32,767 ms or 32 seconds) or long (signed 32-bit, rolling over at 24.8 days into negative numbers), the math will break. Never use int for millis() variables.

2. Check Subtraction Direction

Always subtract the past from the present: currentMillis - previousMillis.
Never use addition to predict the future: if (currentMillis > previousMillis + interval).
When millis() rolls over to 0, currentMillis becomes a small number, while previousMillis + interval remains a massive number. The > check will fail for 49 days. Subtraction works because unsigned integer underflow wraps around perfectly, maintaining the correct mathematical delta.

3. Ensure Proper Initialization in Setup

If you declare unsigned long previousMillis = 0; globally, and your interval is 2 hours, the timer will trigger instantly on boot because currentMillis (e.g., 50ms) minus 0 is greater than the interval if you aren't careful, or it will miscalculate the first cycle. Always initialize your previous time variables to millis() inside setup().

Authoritative Reference: For a deeper dive into unsigned integer wrap-around mechanics, refer to the official Arduino millis() documentation and the BlinkWithoutDelay tutorial.

Extending and Simplifying the Build

How to Simplify: Struct Arrays

If you need to manage 10 relays instead of 2, writing 10 blocks of if/else statements becomes a maintenance nightmare. Simplify the build by defining a struct and an array of timers:

struct TimerTask {
  int pin;
  unsigned long interval;
  unsigned long runtime;
  unsigned long previousMillis;
  bool state;
};

TimerTask tasks[] = {
  {8, 7200000UL, 5000UL, 0, false},
  {9, 1800000UL, 10000UL, 0, false}
};

You can then iterate through the array in loop() with a single for loop, drastically reducing code size and flash memory usage.

How to Extend: Adding Absolute Time

arduino millis only tracks time relative to boot. If the board loses power, the schedule is lost. To extend this build for true real-time scheduling (e.g., "turn pump on at 6:00 AM"), add a DS3231 I2C Real Time Clock (RTC) module. Use the RTC to handle daily schedules, and reserve millis() strictly for short-duration runtime tracking and button debouncing.

Frequently Asked Questions

Why is my arduino millis timer stopping after 49 days?

Your timer is stopping because of the 32-bit unsigned integer overflow. The millis() counter hits 4,294,967,295 and resets to 0. If your code uses signed variables (like long or int) or addition-based logic (previous + interval > current), the math evaluates to a negative number or a false condition, stalling the timer. Switch all timing variables to unsigned long and use subtraction-based logic to fix it.

How to use arduino millis for multiple independent tasks?

Create separate unsigned long previousMillis variables for each task (e.g., previousMillisPump, previousMillisFan). In the loop(), check the delta between millis() and each specific previous variable independently. Because loop() executes thousands of times per second, checking multiple non-blocking conditions sequentially allows them to operate concurrently without interfering with one another.

Does arduino millis keep counting during sleep mode?

No. When the ATmega328P enters deep sleep modes like SLEEP_MODE_PWR_DOWN, the system clock (and therefore Timer0, which drives millis()) is halted to save power. When the microcontroller wakes up via an external interrupt, millis() resumes counting from the exact value it held before sleeping. If you need to track time during sleep, you must use an external RTC or the internal Watchdog Timer (WDT) to estimate elapsed sleep time and manually add it to your variables upon waking.

What is the difference between arduino millis and micros?

While millis() returns milliseconds (1/1,000th of a second), micros() returns microseconds (1/1,000,000th of a second). micros() is essential for high-speed protocols like bit-banging WS2812B addressable LEDs or reading ultrasonic sensors. However, micros() overflows much faster—every 70 minutes—so the unsigned subtraction logic is even more critical when working with microsecond timing.