Every Arduino project needs to deal with time. Whether you are blinking an LED, reading a sensor every 500 ms, debouncing a button, or running multiple tasks simultaneously — you need precise, reliable timing. Most beginners start with delay(). Most experienced developers rarely use it. This guide explains why, and how to write Arduino code that handles time correctly without blocking your program’s execution.
What Is delay() and What Is It Doing to Your Code?
delay(ms) pauses the entire program for the specified number of milliseconds. During this time, nothing else runs. No sensor readings, no button checks, no serial communication — the microcontroller simply sits in a busy wait loop counting clock cycles.
void loop() {
digitalWrite(LED_PIN, HIGH);
delay(1000); // Arduino is completely frozen for 1 second
digitalWrite(LED_PIN, LOW);
delay(1000); // Frozen again
// If a button is pressed during any delay, it WILL be missed
}
delay() is implemented using Timer0, which runs at 16 MHz and overflows every 64 microseconds. The delay() function spins in a loop checking this counter until the specified time has elapsed.
When is delay() acceptable?
- In setup(), to give external hardware time to initialise (e.g., delay(100) after power-on).
- One-shot sequences where you genuinely want everything to stop (e.g., startup animation).
- Very simple single-task sketches with no responsiveness requirements.
When does delay() fail you?
- Any project with more than one independently timed task.
- Projects that must respond to button presses, encoder inputs, or serial commands at any time.
- Projects driving stepper motors or other hardware that needs continuous background attention.
millis() Explained: The Non-Blocking Alternative
millis() returns the number of milliseconds that have elapsed since the Arduino was last powered on or reset. It is backed by Timer0 and updates via a hardware interrupt every 64 microseconds (Timer0 overflow), providing millisecond-resolution timing.
unsigned long startTime = millis();
// ... do other things ...
if (millis() - startTime >= 1000) {
// 1 second has passed
}
Key properties of millis():
- Returns unsigned long (32-bit, 0 to 4,294,967,295).
- Overflows (wraps back to 0) after approximately 49.7 days.
- Accuracy: typically plus or minus 1 ms per reading; slightly affected by long ISRs.
- Resolution: 1 ms on standard 16 MHz boards.
- Does not block — calling millis() takes only a few microseconds and your code continues immediately.
The millis() Pattern: Non-Blocking Timing in Practice
The standard pattern stores the last time an action occurred in a previousMillis variable, then compares the current time against it on every loop iteration:
unsigned long previousMillis = 0;
const unsigned long INTERVAL = 1000; // 1 second
bool ledState = LOW;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= INTERVAL) {
previousMillis = currentMillis;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
// Everything here runs on EVERY loop iteration — nothing is blocked!
checkButton();
readSensor();
updateDisplay();
}
Notice that previousMillis = currentMillis is used (not previousMillis += INTERVAL). Both approaches work, but they have different behaviour under load:
- previousMillis = currentMillis — If a loop iteration takes longer than expected and the interval is exceeded, the next interval starts from the current time. This prevents cascading catch-up.
- previousMillis += INTERVAL — Keeps intervals precisely equal even if individual iterations are slow. Use this when strict period accuracy matters (e.g., data logging at exact 1s intervals).
micros() for Microsecond Precision
micros() returns the number of microseconds since last reset. It provides 4 microsecond resolution on 16 MHz boards (8 microseconds on 8 MHz boards like the Pro Mini 3.3V).
// Measure pulse width with microsecond precision unsigned long pulseStart = micros(); while (digitalRead(ECHO_PIN) == HIGH); unsigned long pulseWidth = micros() - pulseStart; // Convert to distance: pulseWidth / 58 = cm
micros() overflows after approximately 70 minutes (2^32 microseconds). The same overflow-safe subtraction technique used for millis() works identically for micros().
Handling millis() Overflow Correctly
After 49.7 days, millis() wraps from 4,294,967,295 back to 0. In reality, the subtraction pattern handles overflow automatically due to unsigned arithmetic wraparound:
Suppose millis() just wrapped: currentMillis = 100, and previousMillis was set just before the wrap: previousMillis = 4294967200. The subtraction 100 – 4294967200 using 32-bit unsigned arithmetic equals 196 ms, which is the correct elapsed time.
This works perfectly as long as:
- Both variables are declared as unsigned long — not int or long.
- You use subtraction (currentMillis – previousMillis), not comparison (currentMillis > previousMillis + INTERVAL — this breaks at overflow).
// WRONG — breaks when millis() overflows!
if (millis() > previousMillis + INTERVAL) { ... }
// CORRECT — always works
if (millis() - previousMillis >= INTERVAL) { ... }
Multitasking with millis(): Running Multiple Timers
The power of millis() becomes clear when you run multiple independent tasks in the same sketch — something impossible with delay():
// Task 1: Blink LED every 500ms
unsigned long led_prev = 0;
const unsigned long LED_INTERVAL = 500;
bool ledState = LOW;
// Task 2: Read sensor every 2 seconds
unsigned long sensor_prev = 0;
const unsigned long SENSOR_INTERVAL = 2000;
// Task 3: Check button every 50ms
unsigned long btn_prev = 0;
const unsigned long BTN_INTERVAL = 50;
void loop() {
unsigned long now = millis();
if (now - led_prev >= LED_INTERVAL) {
led_prev = now;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
if (now - sensor_prev >= SENSOR_INTERVAL) {
sensor_prev = now;
float temp = readTemperature();
Serial.println(temp);
}
if (now - btn_prev >= BTN_INTERVAL) {
btn_prev = now;
int btnState = digitalRead(BTN_PIN);
if (btnState == LOW) onButtonPress();
}
}
This is cooperative multitasking — each task yields control back to the main loop after doing its work. The key requirement is that each task’s work takes a short, bounded time. Tasks that take more than 10 ms will cause jitter in other tasks’ timing.
Timer Libraries: SimpleTimer, TaskScheduler, and More
For more complex projects, libraries abstract the millis() pattern into cleaner APIs:
TaskScheduler: The most capable and actively maintained option. Supports task enable/disable, finite run counts, callbacks, priority, and sleep modes.
#include <TaskScheduler.h>
void blinkCallback();
void sensorCallback();
Task tBlink(500, TASK_FOREVER, &blinkCallback);
Task tSensor(2000, TASK_FOREVER, &sensorCallback);
Scheduler runner;
void blinkCallback() {
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
}
void sensorCallback() {
Serial.println(analogRead(A0));
}
void setup() {
Serial.begin(9600);
runner.init();
runner.addTask(tBlink);
runner.addTask(tSensor);
tBlink.enable();
tSensor.enable();
}
void loop() {
runner.execute();
}
TimerOne / TimerThree libraries: These control hardware Timer1 or Timer3 directly, providing interrupt-driven callbacks at precise intervals — not subject to loop() jitter. Useful for generating audio tones, precise PWM, or stepper motor control.
#include <TimerOne.h>
void timerISR() {
// Called EXACTLY every 1ms regardless of what loop() is doing
stepperDriver.tick();
}
void setup() {
Timer1.initialize(1000); // 1000 us = 1ms interval
Timer1.attachInterrupt(timerISR);
}
Frequently Asked Questions
Does millis() drift over time and become inaccurate?
The accuracy of millis() depends on the resonator or crystal on your board. Arduino Uno uses a ceramic resonator with plus or minus 0.5% accuracy, meaning after 1 hour it could be off by plus or minus 18 seconds. Arduino boards that use a crystal oscillator (rather than resonator) are typically accurate to plus or minus 20 ppm — about plus or minus 1.7 seconds per day. For applications needing real-time accuracy, add an external RTC module (DS3231 is plus or minus 2 ppm) and periodically re-sync your timing.
Can I use delay() and millis() in the same sketch?
Yes. delay() internally uses millis() and does not corrupt any state. You can freely mix them. The practical concern is that delay() blocks your sketch while millis()-based code in other parts of the loop cannot run. Many developers use delay() in setup() for hardware initialisation delays, and millis() everywhere in loop().
Why does my millis()-based timer fire slightly late sometimes?
The millis() pattern only checks timing on each pass through loop(). If some other part of your loop takes 50 ms (perhaps a slow I2C read or serial print), your 100 ms timer will fire at 150 ms instead of 100 ms. To fix this: ensure no single task in your loop blocks for more than a few milliseconds, avoid delay() anywhere in loop(), and consider moving critical timing to hardware timer interrupts using the TimerOne library.
What is the difference between millis() and micros() for timing?
millis() has 1 ms resolution and overflows every 49.7 days — suitable for most task scheduling, debouncing, and periodic operations. micros() has 4 microsecond resolution and overflows every 70 minutes — suitable for measuring short pulses (ultrasonic sensors, IR signals), generating precise waveforms, or profiling code execution time. Use micros() when you need finer granularity than 1 ms, but watch for the much sooner overflow.
How do I run a task exactly N times and then stop?
Track a counter alongside your timer. Increment it each time the task fires, and stop executing when it reaches the maximum. The TaskScheduler library provides a built-in mechanism for this: Task tMyTask(100, 5, &callback) where 5 is the number of iterations before automatic disabling.
Browse our complete range of Arduino boards and kits at Zbotic.in — genuine products, expert support, fast delivery across India.
Add comment