Arduino delay() vs millis(): Non-Blocking Scheduling in Practice
delay() freezes the whole sketch, not just one task. Here is how millis()-based elapsed-time checks let a blink pattern and a debounced button run together — and what that costs.
12 Oct 2025, 23:21 UTC

The sketch that stops listening
A typical first Arduino sketch does two things: blink an LED every 500 ms and print something when a button is pressed. Written with delay(500), it works — until you press the button during one of those 500 ms windows and nothing happens. The press is not lost because the button is broken; it is lost because delay() stops the whole sketch, not just the blink.
That is the decision this post is about: when to keep delay(), and when to switch to elapsed-time checks built on millis(). The short version: if your sketch has more than one thing to service, millis() is the standard fix, and it costs you some readability.
Why delay() blocks everything
An Arduino sketch runs one loop() function repeatedly on a single core. delay(ms) busy-waits for that duration before returning, so loop() does not iterate during the wait. No digitalRead(), no serial parsing, no second LED pattern, no sensor polling. Interrupts can still fire, but nothing in your main code runs.
For a sketch whose only job is a heartbeat LED, that is fine. For anything else, it is the bug.
The elapsed-time pattern
Instead of waiting, each task records when it last ran and asks "has enough time passed?" on every pass through loop(). The comparison uses unsigned subtraction:
unsigned long now = millis();
if (now - lastRun >= interval) {
lastRun = now;
// do the work
}millis() returns the number of milliseconds since the board started, as an unsigned long. On the common 32-bit cores that counter wraps back to zero roughly every 49.7 days. Writing the test as now - lastRun >= interval rather than now >= lastRun + interval is what makes it survive that wrap: unsigned arithmetic wraps modulo 2^32, so the difference stays correct across the rollover. The absolute-timestamp form does not.
This is well-established behavior in the Arduino core, but the exact implementation lives in each board core, so check your core's source or documentation if you need certainty for a specific board.
A worked example: blink plus a debounced button
Two tasks share one loop. The LED toggles every 500 ms; the button is debounced with a 30 ms window and counts presses. Wiring: the button connects the input pin to ground, and the pin uses INPUT_PULLUP, so a press reads LOW.
const uint8_t LED_PIN = LED_BUILTIN;
const uint8_t BUTTON_PIN = 2;
const unsigned long BLINK_MS = 500;
const unsigned long DEBOUNCE_MS = 30;
unsigned long lastBlink = 0;
bool ledState = false;
unsigned long lastChange = 0;
int lastReading = HIGH;
int stableState = HIGH;
unsigned long presses = 0;
void setup() {
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
unsigned long now = millis();
if (now - lastBlink >= BLINK_MS) {
lastBlink = now;
ledState = !ledState;
digitalWrite(LED_PIN, ledState);
}
int reading = digitalRead(BUTTON_PIN);
if (reading != lastReading) {
lastChange = now;
lastReading = reading;
}
if (now - lastChange >= DEBOUNCE_MS && reading != stableState) {
stableState = reading;
if (stableState == LOW) {
presses++;
Serial.print("presses: ");
Serial.println(presses);
}
}
}Two details matter. First, now is captured once per pass so both tasks compare against the same instant. Second, the debounce logic only accepts a new state after the reading has been stable for DEBOUNCE_MS; mechanical buttons bounce for a few milliseconds and would otherwise register several presses.
BUTTON_PIN = 2 is arbitrary here because the button is polled, not interrupt-driven. If you later move to attachInterrupt(), interrupt-capable pin numbers differ by board and microcontroller — check your board's documentation rather than copying a pin number from another board. An ISR should also set a volatile flag and return quickly, with the real work done in loop().
What the pattern costs
- Complexity. Each task needs its own timestamp and often its own state variable. A three-step sequence becomes a small state machine instead of three
delay()calls. That is harder to read and easier to get wrong. - Drift. Setting
lastBlink = nowmakes each interval slightly longer than requested, because it includes the time the rest of the loop took. UsinglastBlink += BLINK_MSkeeps the long-term average closer but can fire several times in a row after a long stall. Pick based on whether you care about average rate or spacing. - Power.
millis()still requires the microcontroller to be awake and looping. Battery-powered designs usually pair non-blocking logic with sleep modes and a timer or pin wake-up source. Sleep APIs vary between cores, so treat that as a separate design step. - Resolution and accuracy.
millis()resolution and accuracy depend on the clock source and core configuration. It is not a calibrated time reference, and it is not a real-time clock — wall-clock timestamps need an RTC module.micros()wraps much sooner (about 71 minutes on a 32-bit microsecond counter), so it suits only short intervals.
How to check it on your board
- Build the example above with your IDE and board core, and note the versions you used.
- Confirm the LED keeps blinking at a steady rate while you press the button repeatedly — the presses should still be counted. Equivalent
delay()-based code cannot do both. - Open the Serial Monitor at 9600 baud and log
millis()alongside each event to see the actual intervals your loop achieves. - Test edge cases: press-and-hold, very rapid presses, and a long blink interval. No event should be missed, and
loop()should never stop iterating. - If you need to reason about rollover, subtract two logged timestamps near the wrap rather than comparing absolute values.
When to keep delay()
If the sketch genuinely has one job — a status LED, a startup settle pause, a one-shot calibration step before the main loop — delay() is shorter and clearer, and there is nothing else to starve. The switch to millis() is worth making at the moment you add a second thing that needs attention, not before.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.