Writing blocking code is one of the most common mistakes people make when programming Arduino. This type of code stops the microcontroller from doing anything else while it waits.
This usually happens when you use delay() or loops that run for a long time.
Blocking code works for simple demos, but it doesn’t work for long in real projects where you need to
:
- Read sensors continuously
- Respond to buttons instantly
- Control multiple outputs at once
- Maintain timing accuracy
- Avoid freezes and missed events
By using time checks, state machines, and events instead of delays, non-blocking code lets your Arduino do more than one thing “at the same time.”
This tutorial explains:
- What blocking vs non-blocking code really means
- Why
delay()is dangerous in real projects - Core non-blocking techniques
- Many practical code examples
- Common mistakes and best practices
What Is Blocking Code?
Blocking Code Defined
Blocking code halts program execution until a condition is met or time passes.
Classic Blocking Example
void loop() {
digitalWrite(13, HIGH);
delay(1000);
digitalWrite(13, LOW);
delay(1000);
}
While delay(1000) runs:
- No buttons are read
- No sensors are updated
- No communication is handled
The CPU is idle but unavailable.
Why Blocking Code Is a Problem
Blocking code causes:
- Missed button presses
- Slow or unresponsive interfaces
- Inaccurate timing
- Impossible multitasking
- Unscalable code
In embedded systems, waiting is a bug, not a feature.
The Arduino loop() Is Already a Scheduler
This is a key mindset shift.
void loop() {
// runs thousands of times per second
}
Arduino already gives you:
- A continuously running main loop
- Deterministic execution order
Non-blocking code simply uses this loop correctly.
Core Principle of Non-Blocking Code
Instead of:
Do task → wait → do next task
Think:
Check if task should run → run briefly → return
Each task:
- Runs fast
- Never waits
- Checks time or conditions
Technique 1: Using millis() Instead of delay()
Basic millis() Timing Pattern
unsigned long lastTime = 0;
const unsigned long interval = 1000;
void loop() {
unsigned long now = millis();
if (now - lastTime >= interval) {
lastTime = now;
digitalWrite(13, !digitalRead(13));
}
}
Why This Is Non-Blocking
- No waiting
- Code continues executing
- Other logic can run freely
millis() Overflow Safety
millis() overflows roughly every 49 days. This pattern is safe:
if (now - lastTime >= interval)
Never compare absolute values:
// unsafe
if (now > lastTime + interval)
Technique 2: Multiple Timed Tasks
Blink LED + Read Button (Simultaneously)
unsigned long ledTimer = 0;
const unsigned long ledInterval = 500;
void loop() {
unsigned long now = millis();
if (now - ledTimer >= ledInterval) {
ledTimer = now;
digitalWrite(13, !digitalRead(13));
}
if (digitalRead(2) == LOW) {
Serial.println("Button pressed");
}
}
Each task:
- Has its own timer
- Runs independently
- Never blocks others
Technique 3: State Machines (Non-Blocking by Design)
State machines and non-blocking code go hand-in-hand.
Blocking Version (Bad)
digitalWrite(led, HIGH);
delay(500);
digitalWrite(led, LOW);
delay(500);
Non-Blocking State Machine Version
enum BlinkState { ON, OFF };
BlinkState state = OFF;
unsigned long lastChange = 0;
void loop() {
unsigned long now = millis();
switch (state) {
case OFF:
if (now - lastChange >= 500) {
digitalWrite(13, HIGH);
state = ON;
lastChange = now;
}
break;
case ON:
if (now - lastChange >= 500) {
digitalWrite(13, LOW);
state = OFF;
lastChange = now;
}
break;
}
}
Technique 4: Event-Driven Logic
Example: Button Event Without Delay
bool lastButton = HIGH;
void loop() {
bool currentButton = digitalRead(2);
if (lastButton == HIGH && currentButton == LOW) {
Serial.println("Button pressed event");
}
lastButton = currentButton;
}
The code:
- Detects transitions
- Reacts instantly
- Never waits
Technique 5: Non-Blocking Sensor Sampling
Blocking Sensor Read (Bad)
delay(1000);
int value = analogRead(A0);
Non-Blocking Sensor Sampling
unsigned long sensorTimer = 0;
const unsigned long sensorInterval = 1000;
void loop() {
unsigned long now = millis();
if (now - sensorTimer >= sensorInterval) {
sensorTimer = now;
int value = analogRead(A0);
Serial.println(value);
}
}
Technique 6: Cooperative Multitasking Pattern
Structure
void loop() {
taskBlink();
taskReadButton();
taskReadSensor();
}
Each task:
- Returns immediately
- Manages its own timing
Example Tasks
void taskBlink() {
static unsigned long t = 0;
if (millis() - t >= 500) {
t = millis();
digitalWrite(13, !digitalRead(13));
}
}
void taskReadButton() {
if (digitalRead(2) == LOW) {
Serial.println("Button");
}
}
Technique 7: Avoiding Blocking Loops
Blocking While Loop (Bad)
while (digitalRead(2) == HIGH) {
// stuck here
}
Non-Blocking Alternative
if (digitalRead(2) == HIGH) {
// condition met, continue normally
}
Or move logic into a state machine.
Technique 8: Timeouts Without delay()
Example: Wait for Input With Timeout
unsigned long start = millis();
const unsigned long timeout = 5000;
void loop() {
if (digitalRead(2) == LOW) {
Serial.println("Input received");
} else if (millis() - start >= timeout) {
Serial.println("Timeout");
}
}
Technique 9: Non-Blocking Serial Handling
Blocking Serial Read (Bad)
while (!Serial.available()) {}
Non-Blocking Serial Read
if (Serial.available()) {
char c = Serial.read();
}
Common Non-Blocking Mistakes
Mistake 1: Mixing delay() With millis()
This breaks timing assumptions and responsiveness.
Mistake 2: Long Computations in loop()
Heavy math or string processing can still block execution.
Mistake 3: One Timer for Everything
Each task should have its own timing control.
When delay() Is Acceptable
Rare cases:
- Simple demos
- One-time startup sequences
- Debug-only sketches
Even then, it should be temporary.
Non-Blocking Design Checklist
- No
delay()inloop() - No
whileloops waiting for conditions - All tasks return quickly
- Timing uses
millis() - States represent behavior
- Inputs checked continuously
Putting It All Together: Complete Example
unsigned long ledTimer = 0;
unsigned long sensorTimer = 0;
void loop() {
unsigned long now = millis();
if (now - ledTimer >= 500) {
ledTimer = now;
digitalWrite(13, !digitalRead(13));
}
if (now - sensorTimer >= 1000) {
sensorTimer = now;
Serial.println(analogRead(A0));
}
if (digitalRead(2) == LOW) {
Serial.println("Button pressed");
}
}
This sketch:
- Blinks LED
- Reads sensor
- Detects button All without blocking.
Final Thoughts
Non-blocking code is the single most important skill for writing reliable Arduino projects.
Once you abandon delay() and embrace:
millis()- State machines
- Event-driven logic
Your code becomes:
- Faster
- More responsive
- Easier to debug
- Much more scalable

