Blink Without Delay Pattern
Blink Without Delay Pattern
Overview
Section titled “Overview”This section covers the non-blocking timing pattern using millis(), a fundamental technique for ESP32 development. By the end of this section, you will be able to:
- Replace
delay()with the non-blockingmillis()pattern - Manage multiple independent timing intervals simultaneously
- Understand why
delay()is problematic in IoT applications - Implement the pattern in the Basic Sketch architecture
Prerequisites
Section titled “Prerequisites”- Basic Sketch architecture (01-11)
- Understanding of how
setup()andloop()work
Key Concepts
Section titled “Key Concepts”The Problem with delay()
Section titled “The Problem with delay()”The delay() function halts all code execution for the specified duration. During this time:
- MQTT connection drops:
client.loop()is not called, so the connection times out - Sensor readings are missed: If a sensor interrupt fires during the delay, it is ignored
- Wi-Fi disconnection is not detected: The ESP32 cannot process Wi-Fi events
- Button presses are missed: Physical input is not polled
- Response time degrades: The device appears unresponsive
In short, delay() turns a multitasking-capable dual-core ESP32 into a single-task device.
The millis() Solution
Section titled “The millis() Solution”The millis() function returns the number of milliseconds since the ESP32 started (rolls over after ~50 days). By comparing the current millis() against a stored “previous” value, we can determine when an action is due — without blocking.
The Pattern:
unsigned long previousMillis = 0;const unsigned long INTERVAL = 1000; // 1 second
void loop() { unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= INTERVAL) { previousMillis = currentMillis; // Do the timed action }
// Other code runs immediately, not blocked}millis() Rollover
Section titled “millis() Rollover”millis() resets to 0 after approximately 50 days. However, the subtraction currentMillis - previousMillis works correctly even after rollover because the result is an unsigned long:
Scenario: previousMillis = 4,294,967,000 (near rollover) currentMillis = 100 (after rollover) currentMillis - previousMillis = 100 - 4,294,967,000 = 102 (correct!)This works due to unsigned integer arithmetic wrapping — no special handling is needed.
Implementation Steps
Section titled “Implementation Steps”Step 1: Single Timer — LED Blink
Section titled “Step 1: Single Timer — LED Blink”The classic “Blink Without Delay” example for ESP32:
const int LED_PIN = 2; // Built-in LED on most ESP32 DevKits
// Timing variablesunsigned long previousBlinkMillis = 0;const unsigned long BLINK_INTERVAL = 1000; // 1 second
int ledState = LOW;
void setup() { pinMode(LED_PIN, OUTPUT);}
void loop() { unsigned long currentMillis = millis();
// Non-blocking blink check if (currentMillis - previousBlinkMillis >= BLINK_INTERVAL) { previousBlinkMillis = currentMillis;
// Toggle LED ledState = (ledState == LOW) ? HIGH : LOW; digitalWrite(LED_PIN, ledState); }
// Other code can go here — it runs immediately! // For example: check MQTT, read sensors, etc.}Step 2: Multiple Independent Timers
Section titled “Step 2: Multiple Independent Timers”The real power of millis() is managing multiple concurrent tasks with different intervals:
const int LED_PIN = 2;const int STATUS_LED_PIN = 15;
// Timer 1: LED blink (fast)unsigned long previousBlinkMillis = 0;const unsigned long BLINK_INTERVAL = 500; // 0.5 seconds
// Timer 2: Status LED (slow)unsigned long previousStatusMillis = 0;const unsigned long STATUS_INTERVAL = 3000; // 3 seconds
// Timer 3: Serial printunsigned long previousPrintMillis = 0;const unsigned long PRINT_INTERVAL = 10000; // 10 seconds
int ledState = LOW;int statusLedState = LOW;
void setup() { Serial.begin(115200); pinMode(LED_PIN, OUTPUT); pinMode(STATUS_LED_PIN, OUTPUT);}
void loop() { unsigned long currentMillis = millis();
// Timer 1: Fast LED blink (500ms) if (currentMillis - previousBlinkMillis >= BLINK_INTERVAL) { previousBlinkMillis = currentMillis; ledState = !ledState; digitalWrite(LED_PIN, ledState); }
// Timer 2: Slow status LED (3000ms) if (currentMillis - previousStatusMillis >= STATUS_INTERVAL) { previousStatusMillis = currentMillis; statusLedState = !statusLedState; digitalWrite(STATUS_LED_PIN, statusLedState); }
// Timer 3: Serial heartbeat (10000ms) if (currentMillis - previousPrintMillis >= PRINT_INTERVAL) { previousPrintMillis = currentMillis; Serial.print("Heartbeat - Uptime: "); Serial.print(currentMillis / 1000); Serial.println(" seconds"); }}Step 3: Integrating with the Basic Sketch
Section titled “Step 3: Integrating with the Basic Sketch”In the Basic Sketch (01-11), the non-blocking pattern is used for both LED blinking and periodic data publishing:
#include <Arduino.h>#include <WiFi.h>#include <PubSubClient.h>
// MQTT and Wi-Fi setup (abbreviated — see 01-11 for full code)WiFiClient wifiClient;PubSubClient mqttClient(wifiClient);
// Publish intervalunsigned long previousPublishMillis = 0;const unsigned long PUBLISH_INTERVAL = 5000;
// LED heartbeatunsigned long previousHeartbeatMillis = 0;const unsigned long HEARTBEAT_INTERVAL = 1000;
// MQTT keep-alive checkunsigned long previousMqttMillis = 0;const unsigned long MQTT_CHECK_INTERVAL = 100;
int ledState = LOW;
void setup() { Serial.begin(115200); pinMode(2, OUTPUT);
// Connect Wi-Fi and MQTT (see 01-11) connectToWiFi(); connectToMQTT();}
void loop() { unsigned long currentMillis = millis();
// 1. MQTT maintain (every 100ms — very frequent) if (currentMillis - previousMqttMillis >= MQTT_CHECK_INTERVAL) { previousMqttMillis = currentMillis;
if (!mqttClient.connected()) { connectToMQTT(); } mqttClient.loop(); }
// 2. LED heartbeat (every 1000ms) if (currentMillis - previousHeartbeatMillis >= HEARTBEAT_INTERVAL) { previousHeartbeatMillis = currentMillis; ledState = !ledState; digitalWrite(2, ledState); }
// 3. Publish sensor data (every 5000ms) if (currentMillis - previousPublishMillis >= PUBLISH_INTERVAL) { previousPublishMillis = currentMillis; publishSensorData(); }}Step 4: Advanced Pattern — Multiple Timers with Struct
Section titled “Step 4: Advanced Pattern — Multiple Timers with Struct”For complex projects, organize timers using a struct:
struct Timer { unsigned long previousMillis; unsigned long interval; bool enabled;
Timer(unsigned long interval) : previousMillis(0), interval(interval), enabled(true) {}
bool isReady() { if (!enabled) return false; unsigned long currentMillis = millis(); if (currentMillis - previousMillis >= interval) { previousMillis = currentMillis; return true; } return false; }
void setInterval(unsigned long newInterval) { interval = newInterval; }
void reset() { previousMillis = millis(); }
void disable() { enabled = false; } void enable() { enabled = true; }};
// UsageTimer blinkTimer(500);Timer sensorTimer(5000);Timer publishTimer(30000);
void loop() { if (blinkTimer.isReady()) { // Toggle LED }
if (sensorTimer.isReady()) { // Read sensor }
if (publishTimer.isReady()) { // Publish data }}Verification
Section titled “Verification”- LED blinks at the expected interval without
delay() - Multiple timers run independently at different rates
- MQTT connection remains active (no timeout disconnections)
- Serial output confirms timing accuracy over extended periods
- The struct-based timer approach works for more complex projects
Troubleshooting
Section titled “Troubleshooting”LED blinking faster or slower than expected
Section titled “LED blinking faster or slower than expected”Causes:
previousMillisnot updated after the action- Wrong data type (using
intinstead ofunsigned long)
Solutions:
- Ensure
previousMillis = currentMillis;is inside theifblock - Always use
unsigned longfor millis() storage (anintwill overflow in ~32 seconds) - Verify the interval value is correct (1000 = 1 second, 500 = 0.5 seconds)
Action fires multiple times rapidly
Section titled “Action fires multiple times rapidly”Cause: currentMillis - previousMillis >= interval evaluates true multiple times before the next loop iteration resets it.
Solutions:
- Update
previousMillisimmediately inside theifblock (before the action code) - The pattern
previousMillis = currentMillis;at the start of the block prevents this
Timer appears to stop after some hours
Section titled “Timer appears to stop after some hours”Cause: While millis() rollover is handled by unsigned arithmetic, stored timestamps in other formats may not be.
Solution: The standard pattern currentMillis - previousMillis >= interval is safe. Never convert millis() to signed long or int.
Best Practices
Section titled “Best Practices”- Never use
delay()in the main loop: Reservedelay()forsetup()only (e.g., waiting for Serial or sensor warm-up) - Always use
unsigned long: The data type for storingmillis()values must beunsigned long - Use descriptive variable names:
previousBlinkMillisis clearer thanprevorlastTime - Use
constfor intervals:const unsigned long INTERVAL = 1000;prevents accidental modification - Periodic MQTT check every 50-100ms: This ensures connection stability without wasting CPU
- Consider using the Timer struct for projects with 5+ timers: It reduces code duplication
Summary
Section titled “Summary”delay()blocks all code execution and should be avoided in IoT applications- The
millis()pattern uses unsigned long subtraction to create non-blocking timers - Multiple independent timers can run concurrently, each with different intervals
- The pattern integrates seamlessly with the Basic Sketch architecture
- A struct-based timer abstraction simplifies management of many concurrent timers
millis()rollover after ~50 days is handled correctly by unsigned integer arithmetic