Replace Arduino delay() with Non-Blocking millis() Timing
Convert blocking delay() calls to non-blocking millis() scheduling so Arduino AVR sketches stay responsive to buttons, serial, and sensors while running periodic tasks.
05 Apr 2026, 13:01 UTC

Desired Outcome
Convert sketches that use delay() into non-blocking versions so button presses, serial input, and sensor reads remain responsive while periodic tasks (like LED blinking) continue on schedule. On single-core AVR boards (Uno, Nano, Mega), this is the only way to achieve concurrent behavior without an RTOS.
Prerequisites
- An AVR-based board running the standard Arduino core (Uno, Nano, Mega, or compatible).
- Familiarity with
setup()andloop()structure. - Variables that store timestamps declared as
unsigned longto matchmillis()'s return type. - The Arduino IDE or CLI installed for upload and serial monitoring.
Core Pattern: Subtraction-Based Interval Check
The millis() counter rolls over approximately every 49.7 days. Comparing a future timestamp directly with > breaks at rollover. The safe pattern uses unsigned subtraction, which wraps correctly:
unsigned long previousMillis = 0;
const unsigned long interval = 500; // milliseconds
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// run periodic task here
}
// other non-blocking checks continue immediately
}Because both operands are unsigned long, the subtraction yields the correct elapsed time even when millis() wraps from 0xFFFFFFFF to 0.
Procedure: Refactor a Blinking LED Sketch
1. Identify Every Blocking Call
Search the sketch for delay(), delayMicroseconds(), and library functions that may block (e.g., some pulseIn() usages, certain sensor library reads). Mark each occurrence.
2. Create a Task Function for Each Periodic Action
Isolate the work done during each delay into its own function with its own timestamp and interval:
// Blink task
unsigned long blinkPrevious = 0;
const unsigned long blinkInterval = 500;
bool ledState = false;
void handleBlink() {
unsigned long now = millis();
if (now - blinkPrevious >= blinkInterval) {
blinkPrevious = now;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
}3. Keep loop() Flat
Call each task function sequentially. No nesting, no waits:
void loop() {
handleBlink();
handleSerialEcho();
handleButtonPoll();
// add more tasks as needed
}4. Implement Responsive Input Handling
Serial echo and button polling run every iteration, so latency stays in microseconds:
void handleSerialEcho() {
while (Serial.available()) {
char c = Serial.read();
Serial.write(c);
}
}
void handleButtonPoll() {
static bool lastButton = HIGH;
bool current = digitalRead(2);
if (current != lastButton) {
Serial.print(F("Button "));
Serial.println(current == LOW ? F("pressed") : F("released"));
lastButton = current;
}
}5. Upload and Verify
Connect the board via USB. In the Arduino IDE, select the correct board and port, then click Upload. Open the Serial Monitor at 115200 baud.
Expected Checks
- Blink consistency: The built-in LED toggles every 500 ms visibly.
- Serial echo latency: Type characters in the Serial Monitor; they should echo back within a few milliseconds, even at the exact moment the LED toggles.
- Button response: Press a button wired to pin 2 (with internal pull-up enabled in
setup()); the state change prints immediately. - Loop iteration rate: Add a diagnostic print every 1000 iterations showing
micros()delta. No single iteration should exceed the shortest task interval (here, 500 ms).
Limitations and Known Issues
- Resolution:
millis()ticks roughly every 1.024 ms (Timer0 overflow interrupt). It is unsuitable for microsecond-precision timing. - Library blocking: Third-party libraries may contain hidden
delay()calls. If the sketch still stalls, audit library source or replace with non-blocking alternatives. - Interrupt dependence:
millis()stops incrementing if interrupts are disabled for long periods (e.g., insidenoInterrupts()critical sections or certain ISRs). Keep critical sections short.
Recovery Options
Timing Drift or Stalls
- Search the entire project (including libraries) for remaining
delay()calls:grep -r "delay(" . --include="*.cpp" --include="*.h" --include="*.ino"(run in the sketch folder). - Verify interrupts are enabled: ensure no stray
noInterrupts()without matchinginterrupts(). - Confirm Timer0 is not reconfigured by other code (e.g., PWM libraries that change prescaler).
Rollover Testing Without Waiting 49 Days
In a test build, initialize previousMillis near UINT32_MAX:
unsigned long blinkPrevious = 0xFFFFFF00UL; // ~1 ms before rolloverUpload, open Serial Monitor with timestamp logging, and verify the blink continues without a long pause when millis() wraps.
Practical Verification Checklist
| Check | Method | Pass Criteria |
|---|---|---|
| LED blink period | Visual or logic probe | 500 ms ± 2 ms steady |
| Serial echo latency | Type in Serial Monitor | < 5 ms during blink transitions |
| Button event latency | Press button, watch serial | Print appears same loop iteration |
| Rollover behavior | Test build with init near MAX | No stall > interval after wrap |
| Loop max duration | Print micros() delta periodically | Max delta < shortest interval |
If all checks pass, the sketch is non-blocking and ready for additional tasks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.