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.
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.
| Method | Resolution | Blocking? | Best Use Case | Drift / Rollover Risk |
|---|---|---|---|---|
delay() | ~1 ms | Yes (Halts CPU) | Quick prototyping, simple boot sequences | No rollover, but causes missed inputs |
millis() | ~1 ms | No (Non-blocking) | State machines, debouncing, multitasking | Overflows every 49.7 days |
micros() | 4 µs (at 16MHz) | No (Non-blocking) | Sensor polling, short pulse measurement | Overflows every 70 minutes |
| Hardware Timers (TimerOne) | 0.0625 µs | No (Interrupt-driven) | PWM generation, precise frequency output | Conflicts with analogWrite() / Servo libs |
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
| Component | MCU Pin | Mode | Notes |
|---|---|---|---|
| Red LED (Anode) | D8 | OUTPUT | Cathode to 220Ω to GND |
| Green LED (Anode) | D9 | OUTPUT | Cathode to 220Ω to GND |
| Pushbutton | D2 | INPUT_PULLUP | One side to D2, other to GND |
Wiring Steps
- Insert the pushbutton into the breadboard so it straddles the center trench.
- 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).
- 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.
- Repeat step 3 for the Green LED, connecting the anode to D9.
- 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:
- Wrong Architecture: You are trying to compile an AVR-specific timer library on an ESP32, RP2040, or Nano 33 IoT. Fix: Use the native
Tickerlibrary for ESP32/RP2040, ormbed::Tickerfor Nano 33 IoT. - PWM Pin Conflict: The
ServoandTimerOnelibraries hijack Timer1 on the ATmega328P. This permanently disablesanalogWrite()PWM on Pins 9 and 10. Fix: Move your PWM outputs to Pins 3, 5, 6, or 11 (which use Timer0 and Timer2). - Outdated Library: The library manager has an old fork that lacks SAMD/ARM support. Fix: Update via the Library Manager or switch to the
TimerInterruptlibrary 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().






