If you’ve ever written an Arduino sketch that used delay() and found that buttons stopped responding, sensors missed readings, or your project froze for seconds at a time — you’ve already experienced the core problem that Arduino non-blocking event-driven programming solves. The delay() function halts the entire microcontroller: no inputs are read, no outputs updated, nothing happens. For simple blink programs this is fine. For real projects that must respond to multiple events simultaneously, you need a fundamentally different approach. This guide teaches you exactly that — from the foundational millis() timer pattern, through finite state machines, to hardware interrupts.
Table of Contents
- Why delay() is a Problem
- The millis() Pattern: Core of Non-Blocking Code
- Managing Multiple Independent Timers
- Finite State Machines for Event-Driven Logic
- Hardware Interrupts for Instant Response
- Non-Blocking Button Debounce
- Putting It All Together: A Real Example
- Frequently Asked Questions
- Conclusion
Why delay() is a Problem
The delay(ms) function calls a spin-loop inside the AVR (or ARM) hardware. The processor does nothing but count clock cycles until the specified time passes. During this time:
- No
digitalRead()oranalogRead()calls execute — sensor values are missed - Serial receive buffer can overflow if data arrives faster than your read rate
- No
digitalWrite()executes — other outputs freeze - Software interrupt callbacks (like
attachInterrupthandlers) still fire, but your main loop cannot process their results
Consider a real scenario: you want to blink an LED every 500ms AND read a button press to toggle a second LED. With delay(500), the button read only happens twice per second. Any button press shorter than 500ms may be missed entirely. Now imagine adding a sensor read every 100ms, a serial command parser, and a servo update every 20ms — it becomes impossible with delay().
The solution: never block. Instead, record the time you want something to happen, then check each loop() iteration whether that time has arrived. This is the event-driven model.
The millis() Pattern: Core of Non-Blocking Code
millis() returns the number of milliseconds since the Arduino was last powered on or reset, stored as an unsigned 32-bit integer. It overflows after approximately 49.7 days, but using the subtraction trick below makes your code overflow-safe:
// Non-blocking LED blink using millis()
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();
// Check if it's time to toggle the LED
if (now - lastBlinkTime >= BLINK_INTERVAL) {
lastBlinkTime = now; // Save the time we last toggled
ledState = !ledState;
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
}
// Everything below runs without any delay!
// Other tasks can go here...
}
The key insight: now - lastBlinkTime gives the elapsed time since the last blink. Because both variables are unsigned long, this subtraction works correctly even when millis() overflows (unsigned arithmetic wraps around predictably). When the elapsed time exceeds your interval, take the action and reset the timestamp.
Notice lastBlinkTime = now rather than lastBlinkTime += BLINK_INTERVAL. Both work, but += INTERVAL is preferred when you need precise timing (it accumulates exactly, rather than adding jitter from loop execution time). For most projects the difference is negligible.
Managing Multiple Independent Timers
The real power emerges when you run multiple independent timers in the same loop. Each task gets its own lastTime variable and interval:
// Multiple non-blocking tasks
unsigned long lastLedTime = 0;
unsigned long lastSensorTime = 0;
unsigned long lastSerialTime = 0;
bool ledState = false;
float temperature = 0.0;
void loop() {
unsigned long now = millis();
// Task 1: Blink LED every 500ms
if (now - lastLedTime >= 500) {
lastLedTime = now;
ledState = !ledState;
digitalWrite(13, ledState);
}
// Task 2: Read temperature every 2000ms
if (now - lastSensorTime >= 2000) {
lastSensorTime = now;
temperature = readTemperatureSensor(); // your sensor function
}
// Task 3: Print status every 1000ms
if (now - lastSerialTime >= 1000) {
lastSerialTime = now;
Serial.print("Temp: ");
Serial.println(temperature);
}
// Task 4: Check button every loop iteration (instant response)
checkButton();
}
All three tasks run independently at their own rates, and button checking runs on every loop iteration for instant response. This is fundamentally more scalable than any delay-based approach. As your project grows, add more lastXTime variables and if blocks.
For cleaner code in larger projects, consider using a helper struct or class to group interval, last-time, and callback:
struct Task {
unsigned long interval;
unsigned long lastRun;
void (*callback)();
};
void blinkLed() { /* blink code */ }
void readSensor() { /* sensor code */ }
Task tasks[] = {
{500, 0, blinkLed},
{2000, 0, readSensor}
};
void loop() {
unsigned long now = millis();
for (int i = 0; i < 2; i++) {
if (now - tasks[i].lastRun >= tasks[i].interval) {
tasks[i].lastRun = now;
tasks[i].callback();
}
}
}
Finite State Machines for Event-Driven Logic
A Finite State Machine (FSM) is a programming pattern where your system is always in one of a defined set of states, and events cause transitions between states. This is the most powerful pattern for complex event-driven Arduino code.
Example: a door lock system with states LOCKED, UNLOCKING, OPEN, LOCKING:
enum class LockState {
LOCKED,
UNLOCKING, // servo moving to open position
OPEN,
LOCKING // servo moving to closed position
};
LockState state = LockState::LOCKED;
unsigned long stateEnteredAt = 0;
const unsigned long UNLOCK_TIME = 500; // 500ms to fully unlock
const unsigned long OPEN_TIME = 5000; // Stay open 5 seconds
void updateLock() {
unsigned long now = millis();
switch (state) {
case LockState::LOCKED:
// Wait for keypad input or RFID event
if (accessGranted()) {
state = LockState::UNLOCKING;
stateEnteredAt = now;
startUnlockServo(); // begins servo movement
}
break;
case LockState::UNLOCKING:
if (now - stateEnteredAt >= UNLOCK_TIME) {
state = LockState::OPEN;
stateEnteredAt = now;
}
break;
case LockState::OPEN:
if (now - stateEnteredAt >= OPEN_TIME) {
state = LockState::LOCKING;
stateEnteredAt = now;
startLockServo();
}
break;
case LockState::LOCKING:
if (now - stateEnteredAt >= UNLOCK_TIME) {
state = LockState::LOCKED;
}
break;
}
}
void loop() {
updateLock();
// other tasks here — no blocking anywhere
}
The FSM approach makes the code easy to reason about and extend. Adding a new state (like ALARM) simply means adding an enum value and a case in the switch. Testing is easier too — you can add Serial.print statements that show state transitions, making debugging straightforward.
Hardware Interrupts for Instant Response
Even with millis()-based polling, you can miss short events if your loop takes too long. Hardware interrupts solve this: when a specified pin changes state, the processor immediately pauses whatever it’s doing and runs your Interrupt Service Routine (ISR).
volatile bool buttonPressed = false;
unsigned long lastPressTime = 0;
void buttonISR() {
// Keep ISRs short! Just set a flag.
buttonPressed = true;
lastPressTime = millis();
}
void setup() {
pinMode(2, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), buttonISR, FALLING);
Serial.begin(9600);
}
void loop() {
if (buttonPressed) {
buttonPressed = false; // clear the flag first
Serial.println("Button was pressed!");
// Handle the event here
}
// Rest of loop runs normally
}
Critical rules for ISRs:
- Keep them extremely short. No Serial.print, no delay, no heavy computation. Set a flag and return.
- Declare shared variables as
volatile. This prevents the compiler from caching the value in a register and missing updates from the ISR. - On Uno, only pins 2 and 3 support external interrupts. The Mega adds pins 18–21. Nano Every and 32-bit boards have more options.
- Protect multi-byte reads with
noInterrupts()/interrupts()in the main loop when reading variables updated by ISRs.
Non-Blocking Button Debounce
Mechanical buttons bounce — they make and break contact many times in the first 5–50 milliseconds after being pressed. Without debounce, one press registers as many presses. The non-blocking debounce pattern tracks button state history:
const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_DELAY = 50; // 50ms debounce window
bool lastButtonState = HIGH;
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;
bool buttonJustPressed() {
bool reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonState) {
lastDebounceTime = millis(); // restart debounce timer
}
lastButtonState = reading;
if (millis() - lastDebounceTime >= DEBOUNCE_DELAY) {
if (reading != buttonState) {
buttonState = reading;
if (buttonState == LOW) return true; // button just pressed
}
}
return false;
}
void loop() {
if (buttonJustPressed()) {
Serial.println("Clean button press detected");
}
// All other tasks continue here
}
Putting It All Together: A Real Example
Here is a complete non-blocking sketch that blinks an LED, reads a temperature sensor every 3 seconds, prints status to serial every second, and responds instantly to a button press — all simultaneously, no delays anywhere:
#include <DHT.h>
#define DHTPIN 4
#define DHTTYPE DHT11
DHT dht(DHTPIN, DHTTYPE);
const int LED_PIN = 13;
const int BUTTON_PIN = 2;
unsigned long lastLed = 0, lastSensor = 0, lastSerial = 0;
bool ledState = false;
float temp = 0, hum = 0;
bool lastBtn = HIGH, btnState = HIGH;
unsigned long lastDebounce = 0;
int pressCount = 0;
void setup() {
Serial.begin(9600);
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
dht.begin();
}
void loop() {
unsigned long now = millis();
// Task 1: Blink LED every 1000ms
if (now - lastLed >= 1000) { lastLed = now; ledState = !ledState; digitalWrite(LED_PIN, ledState); }
// Task 2: Read DHT sensor every 3000ms
if (now - lastSensor >= 3000) { lastSensor = now; temp = dht.readTemperature(); hum = dht.readHumidity(); }
// Task 3: Print status every 1000ms
if (now - lastSerial >= 1000) { lastSerial = now; Serial.print("T:"); Serial.print(temp); Serial.print("C H:"); Serial.print(hum); Serial.print("% Presses:"); Serial.println(pressCount); }
// Task 4: Debounced button check (runs every loop)
bool reading = digitalRead(BUTTON_PIN);
if (reading != lastBtn) lastDebounce = now;
lastBtn = reading;
if (now - lastDebounce >= 50 && reading != btnState) {
btnState = reading;
if (btnState == LOW) pressCount++;
}
}
Frequently Asked Questions
When does millis() overflow and does it cause bugs?
millis() overflows after 4,294,967,295 ms — approximately 49.7 days. Using the subtraction pattern now - lastTime with unsigned long variables handles overflow correctly because unsigned subtraction wraps around in a mathematically predictable way. As long as your interval is shorter than 49.7 days (it always is), the timer works correctly through overflow.
Can I replace delay() with millis() in all situations?
For most cases, yes. The only legitimate use of delay() is in setup() for hardware initialisation sequences where blocking is acceptable (like waiting for a sensor to boot). In loop(), delay() should almost never appear in production code. Use millis() for all timing in loop().
What is the minimum resolution of millis() on Arduino?
On the Arduino Uno and Nano (16 MHz AVR), millis() has 1ms resolution for most calls, but occasionally 2ms due to the timer overflow interrupt implementation. For sub-millisecond timing, use micros() which has 4 microsecond resolution on 16 MHz boards. On 48 MHz boards like the Nano Every, micros() has higher resolution.
Are there libraries that make this easier?
Yes. Popular non-blocking timer libraries include: SimpleTimer, TaskScheduler (feature-rich, supports priorities and sleep), and ArduinoThread. These wrap the millis() pattern in clean APIs. Learning the raw pattern first is recommended so you understand what the libraries do under the hood.
What is the difference between polling and interrupts for button reading?
Polling checks the button state every loop iteration — it works when your loop() runs frequently (1000+ times per second). Interrupts trigger immediately when the pin changes, even if your loop is busy. Use interrupts for timing-critical events (encoder counting, pulse width measurement) and polling with debounce for regular buttons in most projects.
Conclusion
Moving from delay()-based code to millis()-based non-blocking patterns is arguably the single most important skill step for an Arduino programmer beyond the basics. Your sketches become more responsive, more scalable, and more professional. The patterns scale from a simple LED blinker all the way to complex multi-sensor data loggers, robotics controllers, and IoT devices. State machines add the structured logic layer needed for anything beyond simple on/off control. And hardware interrupts provide the instant response guarantee that polling cannot offer for time-critical signals. Master these three patterns — millis() timers, FSMs, and interrupts — and you’ll have the tools to build any embedded project you can imagine.
Level up your projects with better hardware. Browse Arduino boards and sensors at Zbotic.in — shipped across India.
Add comment