When I first began building projects with Arduino, I used the delay() function without a second thought. It was simple and worked like magic when blinking an LED. Soon, I learned that delay() stops the program from doing other tasks. This guide explains the problems with delay() and shows you different methods for better timing.
What is delay() and How It Works
The delay() function pauses the program for a set number of milliseconds. For example, delay(1000) pauses the program for one full second. While the code appears simple, it stops the processor from reading sensors, checking inputs, or updating displays. When the processor is waiting, it does nothing else. This makes delay() a blocking function.
Below is a basic sketch that uses delay() to blink an LED:
cpp
void loop() {
digitalWrite(ledPin, HIGH); // Turn LED on
delay(1000); // Wait for 1 second
digitalWrite(ledPin, LOW); // Turn LED off
delay(1000); // Wait for 1 second
}
This style is fine for very simple programs. When you start to build more complex projects, the blocking nature can cause issues.
The Hidden Cost of Using delay()
Using delay() may not seem harmful in a small project. In larger projects, the Arduino cannot perform tasks simultaneously. With delay(), the processor cannot read sensor data, handle communication, or update displays while waiting. This can cause slow responses in projects that require multiple tasks.
Imagine a smart thermostat that needs to read temperature data, update a display, and check for button inputs. With delay(), each pause stops all other tasks. This can lead to missed sensor readings or delayed actions.
A Better Option: Using millis() Instead
The millis() function returns the number of milliseconds since the program started. It does not stop the program. Instead, it gives you a way to compare time. You can run code at specific intervals without pausing the processor. I started using millis() after facing issues with delay() in my projects.
Here is a revised version of the LED blink code using millis():
cpp
const long interval = 1000; // Interval in milliseconds
unsigned long previousMillis = 0; // Store the last time the LED was updated
int ledState = LOW; // Current LED state
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
ledState = (ledState == LOW) ? HIGH : LOW;
digitalWrite(ledPin, ledState);
}
// Other tasks can run here without delay
}
This method lets you perform other tasks in the loop. The Arduino can keep checking sensors or processing inputs while testing for elapsed time.
Simple Interval Timing With millis()
One common pattern is to check if enough time has passed to run a function. When you compare the current millis() value with a saved time, you can run code without blocking. It looks like this:
cpp
if (millis() - previousMillis >= interval) {
previousMillis = millis();
// Execute the task here
}
Using millis() gives you better control over the timing. It also helps keep the operation steady by running code exactly when needed.
Quick Tip: Use a simple if-statement to check time differences. This keeps your code active at all times.
Improved Timing Strategies
A small change can prevent time drift when tasks take longer than expected. Instead of setting previousMillis to the current time, add the interval to previousMillis. This strategy keeps timing more consistent.
cpp
if (millis() - previousMillis >= interval) {
previousMillis += interval;
// Execute the task here
}
This method helps keep your time checks on track. It prevents the drift that may occur if other code takes extra time to run.
Managing Multiple Timed Events
When you have more than one timed event, use separate variables for each. Below is an example with two events running at different intervals:
cpp
unsigned long previousMillis1 = 0;
unsigned long previousMillis2 = 0;
const long interval1 = 1000;
const long interval2 = 5000;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis1 >= interval1) {
previousMillis1 = currentMillis;
// Task for the first event
}
if (currentMillis - previousMillis2 >= interval2) {
previousMillis2 = currentMillis;
// Task for the second event
}
// Continue handling other non-timed tasks here.
}
This allows you to run tasks concurrently without one task stopping the other.
Pro Tip: Keep separate timers for each event to avoid conflicts in your scheduling.
Advanced Timing Techniques With millis()
For more advanced projects, you may use a scheduling method or a state machine. A scheduler lets you run several tasks based on time intervals. The code below shows how to manage multiple tasks using an array.
cpp
define NUM_TASKS 3
unsigned long taskIntervals[NUM_TASKS] = {1000, 2000, 5000};
unsigned long taskLastRun[NUM_TASKS] = {0, 0, 0};
void loop() {
unsigned long currentMillis = millis();
for (int i = 0; i < NUM_TASKS; i++) {
if (currentMillis - taskLastRun[i] >= taskIntervals[i]) {
taskLastRun[i] = currentMillis;
runTask(i); // Call the appropriate task
}
}
}
void runTask(int taskID) {
if (taskID == 0) {
// Code for task 1
}
else if (taskID == 1) {
// Code for task 2
}
else if (taskID == 2) {
// Code for task 3
}
}
A state machine can also help with timing. It allows you to run functions based on different conditions. Take a look at this example:
cpp
enum State {IDLE, ACTIVE, COOLDOWN};
State currentState = IDLE;
unsigned long stateTimer = 0;
void loop() {
unsigned long currentMillis = millis();
switch (currentState) {
case IDLE:
if (/* condition met */ && (currentMillis - stateTimer >= 5000)) {
currentState = ACTIVE;
stateTimer = currentMillis;
}
break;
case ACTIVE:
if (currentMillis - stateTimer >= 10000) {
currentState = COOLDOWN;
stateTimer = currentMillis;
}
break;
case COOLDOWN:
if (currentMillis - stateTimer >= 3000) {
currentState = IDLE;
stateTimer = currentMillis;
}
break;
}
}
This method makes your code more organized when handling timed states.
When to Use micros() for High Precision Tasks
The micros() function works similarly to millis() but returns microseconds. It comes in handy when you need very fine timing. Note that micros() resets around every 70 minutes. Use it for tasks like sensor sampling or pulse measurements.
Below is a simple example that reads a sensor at high speed:
cpp
const unsigned long samplingInterval = 100; // 100 microseconds
unsigned long previousMicros = 0;
void loop() {
unsigned long currentMicros = micros();
if (currentMicros - previousMicros >= samplingInterval) {
previousMicros = currentMicros;
int sensorValue = analogRead(sensorPin);
// Process sensor reading
}
}
Hint: Use micros() for projects that need quick and precise timing steps.
Practical Examples: Replacing delay() with millis()
Let’s look at a few project examples that show how to use these techniques.
Example 1: Blinking Multiple LEDs
Imagine you want three LEDs to blink at different intervals. Use the following code to handle each LED with its own timer:
cpp
const int numLeds = 3;
const int ledPins[numLeds] = {9, 10, 11};
const long intervals[numLeds] = {500, 1000, 2000};
int ledStates[numLeds] = {LOW, LOW, LOW};
unsigned long previousMillis[numLeds] = {0, 0, 0};
void setup() {
for (int i = 0; i < numLeds; i++) {
pinMode(ledPins[i], OUTPUT);
}
}
void loop() {
unsigned long currentMillis = millis();
for (int i = 0; i < numLeds; i++) {
if (currentMillis - previousMillis[i] >= intervals[i]) {
previousMillis[i] = currentMillis;
ledStates[i] = (ledStates[i] == LOW) ? HIGH : LOW;
digitalWrite(ledPins[i], ledStates[i]);
}
}
}
This code keeps each LED blinking on its own schedule. The processor can check other tasks while blinking the LEDs.
Example 2: Non-Blocking Sensor and Display Update
Consider a project that reads temperature and then updates an LCD display. Using delay() would stall the screen update. Replace delay() with millis() as shown below:
cpp
unsigned long previousTempMillis = 0;
const long tempInterval = 2000; // 2 seconds
float temperature = 0.0;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousTempMillis >= tempInterval) {
previousTempMillis = currentMillis;
temperature = readTemperature(); // Assume this function reads temperature
updateDisplay(temperature); // Update the LCD display with the value
}
// Other tasks can be handled here
}
This change lets your project monitor sensors continuously while refreshing the display.
Tips for Smooth Timing in Arduino Projects
Use clear code structure, keep the main loop short, and avoid long blocking calls. Combine the techniques above to create responsive, efficient Arduino applications.
“If you need more precision than the Arduino can provide, consider using a real‑time clock (RTC) module or an external timer.” — Arduino Forum
Conclusion
Understanding the limitations of delay() and leveraging millis() (or micros()) allows you to build more responsive and efficient Arduino projects. By managing time manually, you can run multiple tasks concurrently, prevent time drift, and achieve higher precision when needed.