Skip to content

Blink Without Delay Pattern

Blink Without Delay Pattern

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-blocking millis() pattern
  • Manage multiple independent timing intervals simultaneously
  • Understand why delay() is problematic in IoT applications
  • Implement the pattern in the Basic Sketch architecture
  • Basic Sketch architecture (01-11)
  • Understanding of how setup() and loop() work

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() 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() 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.

The classic “Blink Without Delay” example for ESP32:

const int LED_PIN = 2; // Built-in LED on most ESP32 DevKits
// Timing variables
unsigned 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.
}

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 print
unsigned 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");
}
}

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 interval
unsigned long previousPublishMillis = 0;
const unsigned long PUBLISH_INTERVAL = 5000;
// LED heartbeat
unsigned long previousHeartbeatMillis = 0;
const unsigned long HEARTBEAT_INTERVAL = 1000;
// MQTT keep-alive check
unsigned 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; }
};
// Usage
Timer 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
}
}
  • 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

LED blinking faster or slower than expected

Section titled “LED blinking faster or slower than expected”

Causes:

  • previousMillis not updated after the action
  • Wrong data type (using int instead of unsigned long)

Solutions:

  1. Ensure previousMillis = currentMillis; is inside the if block
  2. Always use unsigned long for millis() storage (an int will overflow in ~32 seconds)
  3. Verify the interval value is correct (1000 = 1 second, 500 = 0.5 seconds)

Cause: currentMillis - previousMillis >= interval evaluates true multiple times before the next loop iteration resets it.

Solutions:

  1. Update previousMillis immediately inside the if block (before the action code)
  2. The pattern previousMillis = currentMillis; at the start of the block prevents this

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.

  • Never use delay() in the main loop: Reserve delay() for setup() only (e.g., waiting for Serial or sensor warm-up)
  • Always use unsigned long: The data type for storing millis() values must be unsigned long
  • Use descriptive variable names: previousBlinkMillis is clearer than prev or lastTime
  • Use const for 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
  1. delay() blocks all code execution and should be avoided in IoT applications
  2. The millis() pattern uses unsigned long subtraction to create non-blocking timers
  3. Multiple independent timers can run concurrently, each with different intervals
  4. The pattern integrates seamlessly with the Basic Sketch architecture
  5. A struct-based timer abstraction simplifies management of many concurrent timers
  6. millis() rollover after ~50 days is handled correctly by unsigned integer arithmetic