The Hidden Cost of delay() on Microcontrollers

Every maker begins their journey with the delay() function. It is intuitive, easy to read, and perfectly adequate for blinking a single LED. However, as soon as your project requires reading a DHT22 sensor, debouncing a mechanical pushbutton, and driving a stepper motor simultaneously, delay() becomes a critical bottleneck. When an ATmega328P (Arduino Uno) or Renesas RA4M1 (Arduino Uno R4) executes a delay, the main CPU loop is entirely halted. The microcontroller is effectively blind to incoming serial data, I2C interrupts, and pin state changes.

To achieve true multitasking on single-core and dual-core microcontrollers, you must transition to non-blocking code. The cornerstone of this architecture is the millis function Arduino core provides. By tracking elapsed time rather than pausing execution, your sketch can continuously poll sensors and manage state machines without missing a beat.

Anatomy of the millis Function Arduino: Under the Hood

To use millis() effectively, you must understand what it is actually measuring. On classic 16 MHz AVR boards like the Uno or Nano, millis() relies on the hardware Timer0.

  • Clock Speed: 16,000,000 Hz
  • Prescaler: 64 (configured by the Arduino core)
  • Timer Resolution: 8-bit (overflows every 256 ticks)

The math dictates that the timer overflows roughly 976.56 times per second. The Arduino core uses a fractional accumulator inside the Timer0 overflow Interrupt Service Routine (ISR) to increment the millis() counter exactly once every 1 millisecond. Modern boards like the ESP32-S3 or Arduino Uno R4 Minima use 32-bit ARM Cortex-M processors where millis() is tied to the SysTick timer or dedicated hardware RTCs, but the software API and the underlying data types remain identical.

The 49.7-Day Rollover: Myth vs. Unsigned Math Reality

The most feared edge case in non-blocking Arduino programming is the "49.7-day rollover." The millis() function returns an unsigned long (a 32-bit unsigned integer). The maximum value of a 32-bit unsigned integer is 4,294,967,295.

At 1,000 increments per second, this counter will overflow and reset to zero after exactly 49.71 days. Many beginners attempt to handle this with flawed logic:

// FLAWED LOGIC - WILL FAIL AT ROLLOVER
if (currentMillis > previousMillis + interval) {
  // Execute action
}

When currentMillis rolls over to 0, and previousMillis is near the maximum value, the addition causes an integer overflow, resulting in unpredictable behavior. The correct, mathematically bulletproof approach relies on the properties of unsigned integer arithmetic:

// CORRECT LOGIC - ROLLOVER SAFE
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
  previousMillis = currentMillis;
  // Execute action
}
Expert Insight: Because unsigned math wraps around predictably, subtracting a slightly larger wrapped value from a smaller wrapped value yields the exact correct elapsed time. If previousMillis is 4,294,967,290 and currentMillis has rolled over to 10, the subtraction 10 - 4,294,967,290 mathematically wraps around to exactly 15 milliseconds. Never use signed long for time tracking.

Architectural Comparison: Blocking vs. Non-Blocking vs. RTOS

Choosing the right timing architecture depends on your microcontroller and project complexity. Below is a decision matrix for 2026 hardware ecosystems.

Method Best Use Case CPU Utilization Complexity Hardware Target
delay() Simple setup routines, basic LED fading 100% Blocked Very Low AVR, ARM, ESP32
millis() State Machine Sensor polling, UI menus, motor control Active Polling Medium AVR, ARM, ESP32
Hardware Timers (ISR) PWM generation, precise encoder reading Interrupt Driven High AVR, ARM
FreeRTOS Tasks Wi-Fi stacks, concurrent heavy processing Scheduled Yielding Very High ESP32, ARM Cortex-M

Building a Non-Blocking State Machine

Let us implement a practical, real-world scenario: blinking an LED while simultaneously reading an I2C BME280 sensor and debouncing a mechanical button. This requires tracking multiple independent time intervals.

unsigned long previousLedMillis = 0;
unsigned long previousSensorMillis = 0;
unsigned long previousButtonMillis = 0;

const long ledInterval = 500;     // Blink every 500ms
const long sensorInterval = 2000; // Read BME280 every 2s
const long debounceInterval = 50; // 50ms debounce

void loop() {
  unsigned long currentMillis = millis();

  // Task 1: LED Blinking
  if (currentMillis - previousLedMillis >= ledInterval) {
    previousLedMillis = currentMillis;
    digitalWrite(LED_PIN, !digitalRead(LED_PIN));
  }

  // Task 2: Sensor Polling
  if (currentMillis - previousSensorMillis >= sensorInterval) {
    previousSensorMillis = currentMillis;
    readBME280Data(); // Non-blocking I2C library required
  }

  // Task 3: Button Debouncing
  if (digitalRead(BUTTON_PIN) != lastButtonState) {
    previousButtonMillis = currentMillis;
    lastButtonState = digitalRead(BUTTON_PIN);
  }
  
  if (currentMillis - previousButtonMillis >= debounceInterval) {
    if (lastButtonState != stableButtonState) {
      stableButtonState = lastButtonState;
      if (stableButtonState == LOW) triggerAction();
    }
  }
}

Advanced Edge Cases: I2C Clock Stretching and Watchdogs

While millis() is robust, it is not immune to hardware-level blocking. Understanding these edge cases separates novices from embedded systems engineers.

1. I2C Clock Stretching

If you are communicating with a slow I2C peripheral (like a cheap OLED display or an aging BME280), the peripheral may pull the SCL line low to "stretch" the clock while it processes data. If your Arduino Wire library is not configured with a timeout, the CPU will hang waiting for the I2C bus to clear. During this hang, millis() will continue to increment in the background (via Timer0 ISR), but your state machine logic will stall, causing missed intervals. Always use modern libraries like Wire with timeout configurations or software I2C fallbacks.

2. Disabling Interrupts

Functions like noInterrupts() are sometimes used to safely read 16-bit variables updated by ISRs. If you disable interrupts for more than 1 millisecond, the Timer0 overflow ISR will be missed, and millis() will permanently lose time. Keep noInterrupts() blocks to mere microseconds.

3. ESP32 Wi-Fi Calibration Spikes

On the ESP32, initializing the Wi-Fi radio or performing a full channel scan can cause momentary CPU monopolization. While the ESP32's hardware timers continue counting, the main loop may experience "jitter" where a 1000ms millis() interval evaluates at 1045ms. For strict timing on ESP32, offload critical timing tasks to a dedicated hardware timer ISR or a high-priority FreeRTOS task pinned to Core 1.

Summary Best Practices

  1. Never use addition to check for elapsed time; always use subtraction to leverage unsigned wrap-around safety.
  2. Update timestamps conditionally: Use previousMillis = currentMillis; rather than previousMillis += interval; to prevent drift accumulation over hours of runtime.
  3. Scope your variables: Declare previousMillis variables globally or as static inside functions so they retain their state between loop iterations.
  4. Avoid blocking libraries: A non-blocking millis() loop is useless if you call a blocking function like Serial.readString() or a synchronous HTTP GET request inside it.

By internalizing the mechanics of the millis() function, you unlock the ability to write professional, responsive firmware capable of handling complex, real-world interactions without requiring the overhead of a full Real-Time Operating System.