The single biggest step-up in Arduino programming skill is moving from delay() to millis()-based timing. While delay() is fine for simple blink sketches, it completely blocks the microcontroller — no button reads, no sensor polling, no serial communication, nothing else can happen while your code sits inside a delay() call. This arduino multitasking millis guide shows you how to run multiple independent tasks simultaneously using timestamp-based timing, giving your Arduino the ability to blink an LED, read a sensor, respond to button presses, and drive a display all at the same time — with no RTOS required.
Table of Contents
- Why delay() Is the Wrong Tool
- How millis() Works
- The Basic millis() Non-blocking Pattern
- Running Multiple Independent Tasks
- State Machines for Complex Sequences
- Button Debouncing Without delay()
- millis() Pitfalls and Overflow Handling
- FAQ
Why delay() Is the Wrong Tool
delay(1000) calls the AVR’s _delay_ms() function, which executes a busy-wait loop. The CPU is alive and counting clock cycles — it simply throws them away doing nothing useful. During that one second:
- A button press is missed if it happens at the wrong time
- Serial incoming data fills the 64-byte hardware buffer and overflows
- A PIR sensor fires but the interrupt is handled too late
- An LCD display cannot be updated
- A second LED cannot blink at a different rate
The real-world consequence: any project that needs to respond to more than one input or output at once becomes impossible to implement cleanly with delay(). Beginners who try work around it end up nesting delays, duplicating code, and building fragile sketches that break whenever a requirement changes.
The millis()-based approach solves this by replacing blocking waits with comparisons: instead of waiting, you record when something last happened, and check whether enough time has passed every time loop() runs. Since loop() runs thousands of times per second, you can check dozens of independent timers without any of them blocking the others.
How millis() Works
millis() returns the number of milliseconds elapsed since the Arduino last reset, as an unsigned long (32-bit, 0–4,294,967,295). It is driven by Timer 0, which is configured by the Arduino core to generate an interrupt every ~1.024 ms (the actual resolution). The overflow interrupt increments an internal counter that millis() reads.
Key properties:
- Non-blocking: calling
millis()takes just a few microseconds - 32-bit unsigned: counts up to ~49.7 days before rolling over to zero
- Resolution: 1 ms (not µs — use
micros()for sub-millisecond timing) - Frozen during interrupts: within an ISR,
millis()does not advance - Disabled by Timer 0 shutdown: using power management (
power_timer0_disable()) stopsmillis()
micros() works identically but returns microseconds and overflows after ~70 minutes. Use it for timing pulse widths, encoder pulses, or other sub-millisecond events.
The Basic millis() Non-blocking Pattern
Here is the canonical non-blocking blink, the “Hello World” of millis()-based programming:
const int LED_PIN = 13;
const unsigned long BLINK_INTERVAL = 500; // milliseconds
unsigned long lastBlinkTime = 0;
bool ledState = false;
void setup() {
pinMode(LED_PIN, OUTPUT);
}
void loop() {
unsigned long now = millis();
if (now - lastBlinkTime >= BLINK_INTERVAL) {
lastBlinkTime = now;
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
// Any other code here runs without delay!
}
The critical pattern has three parts:
- Store the timestamp of when the event last happened (
lastBlinkTime) - Every loop iteration, compute
now - lastBlinkTime - When the elapsed time exceeds the interval, execute the action and update the timestamp
Why now - lastBlinkTime instead of now >= lastBlinkTime + BLINK_INTERVAL? Subtraction handles the millis() overflow correctly (more on this in the pitfalls section). Always subtract, never add.
Running Multiple Independent Tasks
The real power emerges when you add more tasks. Each needs its own timestamp variable and interval constant. Here is a sketch running four completely independent tasks:
// --- Task 1: Blink LED on pin 13 every 500ms ---
const int LED1 = 13;
const unsigned long LED1_INTERVAL = 500;
unsigned long led1Last = 0;
bool led1State = false;
// --- Task 2: Blink LED on pin 12 every 1200ms ---
const int LED2 = 12;
const unsigned long LED2_INTERVAL = 1200;
unsigned long led2Last = 0;
bool led2State = false;
// --- Task 3: Read temperature sensor every 2000ms ---
const unsigned long SENSOR_INTERVAL = 2000;
unsigned long sensorLast = 0;
// --- Task 4: Print uptime every 5000ms ---
const unsigned long PRINT_INTERVAL = 5000;
unsigned long printLast = 0;
void setup() {
Serial.begin(9600);
pinMode(LED1, OUTPUT);
pinMode(LED2, OUTPUT);
}
void loop() {
unsigned long now = millis();
// Task 1
if (now - led1Last >= LED1_INTERVAL) {
led1Last = now;
led1State = !led1State;
digitalWrite(LED1, led1State);
}
// Task 2
if (now - led2Last >= LED2_INTERVAL) {
led2Last = now;
led2State = !led2State;
digitalWrite(LED2, led2State);
}
// Task 3
if (now - sensorLast >= SENSOR_INTERVAL) {
sensorLast = now;
int temp = analogRead(A0); // replace with real sensor read
// process temp...
}
// Task 4
if (now - printLast >= PRINT_INTERVAL) {
printLast = now;
Serial.print("Uptime: ");
Serial.print(now / 1000);
Serial.println(" seconds");
}
}
All four tasks run independently. LED1 blinks at 500ms, LED2 at 1200ms — they immediately fall out of sync, producing the characteristic async blink pattern that is impossible with nested delay() calls. The sensor reads every 2 seconds and Serial prints every 5 seconds, all without any task blocking any other.
Encapsulating Tasks in Functions
For cleaner code, encapsulate each task’s logic and state in its own function:
void taskBlinkLED() {
static unsigned long lastTime = 0;
static bool state = false;
unsigned long now = millis();
if (now - lastTime >= 500) {
lastTime = now;
state = !state;
digitalWrite(13, state);
}
}
void taskReadSensor() {
static unsigned long lastTime = 0;
unsigned long now = millis();
if (now - lastTime >= 2000) {
lastTime = now;
// read sensor...
}
}
void loop() {
taskBlinkLED();
taskReadSensor();
// more tasks...
}
The static keyword inside a function makes the variable retain its value between calls (like a global, but scoped to the function). This pattern keeps all task state encapsulated and makes adding/removing tasks as easy as adding/removing one function call in loop().
State Machines for Complex Sequences
Simple on/off tasks are straightforward with millis(). For sequences — like a traffic light that cycles Green→Yellow→Red with different durations — state machines are the right approach:
enum TrafficState { GREEN, YELLOW, RED };
TrafficState currentState = GREEN;
unsigned long stateStart = 0;
const unsigned long GREEN_TIME = 5000;
const unsigned long YELLOW_TIME = 1500;
const unsigned long RED_TIME = 4000;
void taskTrafficLight() {
unsigned long now = millis();
unsigned long elapsed = now - stateStart;
switch (currentState) {
case GREEN:
digitalWrite(2, HIGH); digitalWrite(3, LOW); digitalWrite(4, LOW);
if (elapsed >= GREEN_TIME) { currentState = YELLOW; stateStart = now; }
break;
case YELLOW:
digitalWrite(2, LOW); digitalWrite(3, HIGH); digitalWrite(4, LOW);
if (elapsed >= YELLOW_TIME) { currentState = RED; stateStart = now; }
break;
case RED:
digitalWrite(2, LOW); digitalWrite(3, LOW); digitalWrite(4, HIGH);
if (elapsed >= RED_TIME) { currentState = GREEN; stateStart = now; }
break;
}
}
The state machine transitions between states based on elapsed time, with each state having its own duration. A pedestrian walk button or emergency vehicle sensor can be incorporated by adding additional state transitions — none of which require delay().
Button Debouncing Without delay()
Mechanical buttons bounce — they generate multiple transitions over a period of ~10–50 ms when pressed. delay(50) is a common but blocking debounce approach. The non-blocking version:
const int BUTTON_PIN = 7;
const unsigned long DEBOUNCE_DELAY = 50;
bool lastButtonState = HIGH;
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;
bool stableState = HIGH;
void taskButton() {
unsigned long now = millis();
bool reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonState) {
lastDebounceTime = now; // Reset debounce timer on any change
}
if (now - lastDebounceTime >= DEBOUNCE_DELAY) {
// Signal has been stable for DEBOUNCE_DELAY ms
if (reading != stableState) {
stableState = reading;
if (stableState == LOW) { // Button pressed (active LOW)
// Handle button press event
Serial.println("Button pressed!");
}
}
}
lastButtonState = reading;
}
This debounce logic only reports a state change after the signal has been stable for 50 ms, eliminating bounce noise — and it runs entirely non-blocking inside loop().
millis() Pitfalls and Overflow Handling
Always Use unsigned long
Never store a millis() value in an int or long (signed). Signed 32-bit integers overflow at ~24 days and produce negative numbers, breaking all timing comparisons. unsigned long overflows at ~49.7 days and wraps correctly with subtraction.
The Subtraction Trick and Overflow Safety
When millis() overflows from 4,294,967,295 back to 0, what happens to now - lastTime?
Example: lastTime = 4,294,967,200, now = 100 (after overflow). With unsigned long subtraction: 100 - 4,294,967,200 = 96 (unsigned arithmetic wraps correctly). The comparison 96 >= INTERVAL works perfectly. This is why the subtraction pattern is overflow-safe and the addition pattern is not.
Never Modify millis() Variables Inside ISRs
If you access millis() timestamps from both loop() and an interrupt service routine, declare them volatile and use atomic reads on AVR (wrap access in noInterrupts() / interrupts() pairs, or use ATOMIC_BLOCK from util/atomic.h).
Task Blocking Still Breaks Everything
If any code inside loop() blocks — even a Wire.requestFrom() that takes 10 ms, or a Serial.print() of a large string — all other tasks miss their deadlines by that amount. For strict timing requirements, use interrupts for time-critical tasks and millis() only for coarse scheduling.
Frequently Asked Questions
Is millis() accurate enough for timing sensors?
millis() has 1 ms resolution and is accurate to within ±0.5% over temperature (limited by the crystal accuracy). For most sensor polling (temperature every 2 seconds, button debounce at 50 ms), this is more than adequate. For precise pulse timing, use micros() (4 µs resolution on 16 MHz boards) or input capture mode on Timer 1 for sub-microsecond accuracy.
Can I use millis() in an interrupt service routine?
You can call millis() inside an ISR, but the value will not advance while inside the ISR (Timer 0 interrupt is disabled during ISR execution). For very short ISRs, this is negligible. For longer ISRs, millis() may be slightly stale. A better pattern: set a volatile bool flag in the ISR and handle the event in loop().
What is the difference between millis() and micros()?
millis() returns elapsed time in milliseconds (1 ms resolution, 49.7-day overflow). micros() returns elapsed time in microseconds (4 µs resolution on 16 MHz, 70-minute overflow). Both are non-blocking. Use millis() for anything 10 ms or longer; use micros() for pulse measurement, encoder reading, or precise signal timing under 10 ms.
My tasks drift over time — how do I fix that?
The common mistake: lastTime = lastTime + INTERVAL vs lastTime = now. Setting lastTime = now causes drift because now is slightly later than lastTime + INTERVAL (by however long your task code took to execute). Setting lastTime = lastTime + INTERVAL (or lastTime += INTERVAL) maintains exact period timing regardless of task execution duration. Use the latter for applications where long-term accuracy matters (real-time clock, metronome, data logging at fixed rates).
Can millis() replace a RTOS for complex projects?
For most Arduino projects with 3–10 independent tasks that are not time-critical (tolerating ±1–10 ms jitter), millis()-based scheduling is perfectly adequate and far simpler than an RTOS. When you need strict real-time guarantees, preemptive task switching, or inter-task communication with synchronization, consider FreeRTOS (available via the Arduino_FreeRTOS library for AVR) or upgrade to an ARM-based board where FreeRTOS is mainstream.
Mastering millis()-based multitasking unlocks the full potential of the Arduino loop and makes your projects more responsive, reliable, and maintainable. Explore all Arduino boards and accessories at zbotic.in Arduino & Microcontrollers — from the classic Uno to the powerful Nano 33 IoT — and start building projects that truly run in parallel.
Add comment