For reliable Arduino timing, abandon delay() and use millis() for millisecond-scale non-blocking tasks, or hardware timers (like Timer1) for microsecond precision. The delay() function halts the CPU, making your project blind to button presses, sensor spikes, and incoming serial data. This guide breaks down the three pillars of Arduino timing, provides a robust non-blocking code template for the Arduino Uno R3, and solves the infamous 49-day rollover bug that crashes long-running projects.

Difficulty: Intermediate | Time: 45 Minutes | Target Board: Arduino Uno R3 (Rev3, ATmega328P-PU, 16MHz)

The Three Pillars of Arduino Timing

Choosing the right timing method depends on your required resolution and whether the CPU needs to multitask. Here is how the three primary methods compare on an AVR-based board like the Uno R3.

MethodResolutionBlocking?Best Use CaseDrift / Rollover Risk
delay()~1 msYes (Halts CPU)Quick prototyping, simple boot sequencesNo rollover, but causes missed inputs
millis()~1 msNo (Non-blocking)State machines, debouncing, multitaskingOverflows every 49.7 days
micros()4 µs (at 16MHz)No (Non-blocking)Sensor polling, short pulse measurementOverflows every 70 minutes
Hardware Timers (TimerOne)0.0625 µsNo (Interrupt-driven)PWM generation, precise frequency outputConflicts with analogWrite() / Servo libs
Bench Note: The Arduino Uno R3 uses a 16MHz ceramic resonator, not a quartz crystal. Ceramic resonators typically have a ±0.5% tolerance. Over 24 hours (86,400 seconds), a 0.5% drift equals 432 seconds (7.2 minutes) of timing error. If your project requires absolute timekeeping (like a clock or data logger), you must add an external RTC module like the DS3231.

Project Build: Non-Blocking Multi-Task Blinker & Debouncer

We will build a circuit that blinks two LEDs at different intervals while simultaneously monitoring a pushbutton for presses, all without using a single delay() call.

Parts List

  • MCU: Arduino Uno R3 (Rev3) or compatible ATmega328P clone
  • LEDs: 2x 5mm Diffused LEDs (1x Red, 1x Green)
  • Resistors: 2x 220Ω (1/4W, for current limiting)
  • Switch: 1x 6x6mm Tactile Pushbutton
  • Wiring: Male-to-male jumper wires, half-size breadboard

Pin Mapping Table

ComponentMCU PinModeNotes
Red LED (Anode)D8OUTPUTCathode to 220Ω to GND
Green LED (Anode)D9OUTPUTCathode to 220Ω to GND
PushbuttonD2INPUT_PULLUPOne side to D2, other to GND

Wiring Steps

  1. Insert the pushbutton into the breadboard so it straddles the center trench.
  2. Connect one leg of the pushbutton to GND on the Arduino. Connect the opposite leg to Digital Pin 2. (We use the internal pull-up resistor, eliminating the need for an external 10kΩ resistor).
  3. Insert the Red LED. Connect the anode (long leg) to D8. Connect the cathode (short leg) to a 220Ω resistor, and the other end of the resistor to GND.
  4. Repeat step 3 for the Green LED, connecting the anode to D9.
  5. Verify all GND connections share a common ground rail on the breadboard.

The Code: Robust millis() State Machine

This code targets the Arduino Uno R3 (AVR architecture). It uses the subtraction method for millis() to natively handle the 49-day rollover bug. It also includes serial error handling to ensure the serial buffer doesn't overflow during rapid button presses.

/*
 * Non-Blocking Multi-Tasking Example
 * Target: Arduino Uno R3 (ATmega328P)
 * Demonstrates: millis() timing, button debouncing, rollover safety
 */

// --- Pin Definitions ---
const int PIN_LED_RED = 8;
const int PIN_LED_GREEN = 9;
const int PIN_BUTTON = 2;

// --- Timing Variables (MUST be unsigned long) ---
unsigned long previousRedMillis = 0;
unsigned long previousGreenMillis = 0;
unsigned long previousDebounceMillis = 0;

// --- Interval Constants ---
const unsigned long RED_INTERVAL = 1000;    // 1 second
const unsigned long GREEN_INTERVAL = 250;   // 250 ms
const unsigned long DEBOUNCE_DELAY = 50;    // 50 ms debounce

// --- State Variables ---
int redState = LOW;
int greenState = LOW;
int buttonState = HIGH;          // HIGH because of INPUT_PULLUP
int lastButtonReading = HIGH;

void setup() {
  // Initialize Serial with error checking
  Serial.begin(115200);
  while (!Serial && millis() < 2000) {
    // Wait for serial port to connect (max 2 seconds)
  }
  
  if (Serial) {
    Serial.println("System Initialized. Non-blocking tasks running.");
  }

  pinMode(PIN_LED_RED, OUTPUT);
  pinMode(PIN_LED_GREEN, OUTPUT);
  pinMode(PIN_BUTTON, INPUT_PULLUP);
}

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

  // --- Task 1: Red LED Blink ---
  if (currentMillis - previousRedMillis >= RED_INTERVAL) {
    previousRedMillis = currentMillis;
    redState = !redState;
    digitalWrite(PIN_LED_RED, redState);
  }

  // --- Task 2: Green LED Blink ---
  if (currentMillis - previousGreenMillis >= GREEN_INTERVAL) {
    previousGreenMillis = currentMillis;
    greenState = !greenState;
    digitalWrite(PIN_LED_GREEN, greenState);
  }

  // --- Task 3: Button Debounce ---
  int reading = digitalRead(PIN_BUTTON);
  
  // If the switch changed, due to noise or pressing:
  if (reading != lastButtonReading) {
    previousDebounceMillis = currentMillis;
  }

  // Check if the state is stable past the debounce delay
  if ((currentMillis - previousDebounceMillis) > DEBOUNCE_DELAY) {
    if (reading != buttonState) {
      buttonState = reading;
      // Button is pressed when pin reads LOW (connected to GND)
      if (buttonState == LOW) {
        if (Serial.availableForWrite() > 20) { // Prevent serial buffer overflow
          Serial.println("Button Pressed! (Non-blocking)");
        }
      }
    }
  }
  
  lastButtonReading = reading;
}

Debugging Timing Failures: The First Three Checks

When your timing logic fails, freezes, or behaves erratically, run through these three diagnostics before rewriting your code.

1. Check for the Rollover Math Error

The Bug: Writing if (millis() + interval > currentMillis).
The Fix: Always use subtraction: if (currentMillis - previousMillis >= interval). When millis() rolls over from 4,294,967,295 back to 0, addition causes an integer overflow that breaks the logic. Subtraction of unsigned integers natively handles the wrap-around in C++.

2. Check Variable Data Types

The Bug: Using int or long to store millis() values.
The Fix: An int on an Uno maxes out at 32,767 (about 32 seconds). A signed long maxes out at 2,147,483,647 (about 24.8 days). You must use unsigned long for all timing variables to get the full 49.7-day range.

3. Check for Hardware Timer Library Conflicts

The Bug: You add a library like TimerOne or Servo to get precise timing, but your PWM outputs stop working, or the code fails to compile on a newer board.

Exact Error String:

#error "This library only supports boards with an AVR or SAM processor"

Ranked Causes & Fixes:

  1. Wrong Architecture: You are trying to compile an AVR-specific timer library on an ESP32, RP2040, or Nano 33 IoT. Fix: Use the native Ticker library for ESP32/RP2040, or mbed::Ticker for Nano 33 IoT.
  2. PWM Pin Conflict: The Servo and TimerOne libraries hijack Timer1 on the ATmega328P. This permanently disables analogWrite() PWM on Pins 9 and 10. Fix: Move your PWM outputs to Pins 3, 5, 6, or 11 (which use Timer0 and Timer2).
  3. Outdated Library: The library manager has an old fork that lacks SAMD/ARM support. Fix: Update via the Library Manager or switch to the TimerInterrupt library which supports a wider range of modern MCUs.

Extending and Simplifying the Build

As your project grows, managing dozens of previousMillis variables becomes a nightmare. Here is how to scale your Arduino timing.

How to Simplify: Use a Task Scheduler

Instead of writing raw millis() state machines, use the TaskScheduler library. It allows you to define tasks as callbacks and handles the timing math, rollover protection, and dynamic interval changes under the hood. This reduces your loop() function to a single line: scheduler.execute().

How to Extend: Add Absolute Timekeeping

If you are building a data logger or an automated grow-light controller, relative timing (millis()) will drift due to the ceramic resonator's temperature sensitivity. Extend your build by wiring a DS3231 I2C RTC (Real Time Clock) module. The DS3231 uses an internal temperature-compensated crystal oscillator (TCXO) that is accurate to ±2ppm (about 1 minute per year). Use the RTClib library to poll the I2C bus every 60 seconds to sync your internal millis() offsets, giving you both non-blocking multitasking and absolute wall-clock accuracy.

Arduino Timing FAQ

Why does my Arduino timing drift after a few hours?

Standard Arduino boards like the Uno R3 and Nano use ceramic resonators for their 16MHz clock source to keep costs low. These components have a tolerance of ±0.5% and are highly sensitive to ambient temperature changes. As the board heats up during operation, the clock speed shifts. For precise long-term timing, you must use a board with a quartz crystal (like the Arduino Due or ESP32) or add an external DS3231 RTC module.

How do I handle the 49-day millis() rollover bug?

The millis() function returns an unsigned long, which maxes out at 4,294,967,295 milliseconds (roughly 49.7 days) before rolling over to zero. You handle this by never adding an interval to millis(). Always subtract the previous timestamp from the current timestamp: if (currentMillis - previousMillis >= interval). Because unsigned integer math in C++ wraps around naturally, this subtraction yields the correct elapsed time even if the rollover occurred between the two readings.

Can I use delay() inside an interrupt service routine (ISR)?

No. You must never use delay() or millis() inside an ISR. The delay() function relies on a hardware timer interrupt to track time, but global interrupts are disabled while an ISR is executing. Calling delay() inside an ISR will cause the microcontroller to freeze in an infinite loop. Keep ISRs as short as possible—set a volatile flag variable inside the ISR, and handle the timing and heavy processing inside the main loop().