Why delay() Eats Your Button Presses — and the millis() Pattern That Fixes It
delay() busy-waits and silently drops button presses and Serial data. Learn the rollover-safe millis() subtraction idiom with a blink-plus-button sketch, its jitter trade-offs, and when hardware timers win.
10 Nov 2025, 08:07 UTC

Wire up a pushbutton next to a blinking LED, write the obvious sketch — digitalWrite(LED_BUILTIN, HIGH); delay(1000); digitalWrite(LED_BUILTIN, LOW); delay(1000); — and then try to read that button. Half the time it feels broken. It isn't broken; your sketch is just asleep at the wheel. On classic AVR boards like the Uno, delay() is a hard busy-wait: for the full interval, no other line of your code runs. Button transitions go unread, and incoming Serial bytes can overrun the small RX buffer (64 bytes on Uno-class cores) while you wait.
The fix is a pattern, not a library: keep timestamps, and let loop() become a tiny cooperative scheduler. This post walks through the rollover-safe idiom, a combined blink-plus-button sketch you can flash today, and the honest trade-offs — including the point where you should stop polling and reach for a hardware timer instead.
What millis() actually gives you
millis() returns the milliseconds since power-on or reset as an unsigned long — 32 bits on AVR and most other cores. On AVR hardware it's maintained by the Timer0 overflow interrupt, giving roughly 1 ms resolution, and it wraps back to zero after about 49.7 days (that figure is core-dependent; treat it as "about seven weeks on an Uno"). You can confirm the implementation yourself by opening cores/arduino/wiring.c in your installed AVR core.
Because it's interrupt-driven, one caveat matters: millis() does not advance reliably while you're inside another interrupt service routine. Keep ISRs short, and never call delay() in one.
The subtraction idiom (and the bug it prevents)
The tempting way to schedule a task is if (millis() > lastRun + interval). Don't. When millis() wraps past 2^32, that comparison breaks and you get a once-every-49-days bug — the worst kind to diagnose.
The safe form uses unsigned subtraction:
unsigned long now = millis();
if ((unsigned long)(now - lastRun) >= interval) {
lastRun = now;
// do the periodic work
}Unsigned arithmetic wraps modulo 2^32, so now - lastRun yields the correct elapsed time even across the rollover boundary. You don't have to wait 49 days to prove it: initialize lastRun = (unsigned long)0 - interval at startup and watch the task fire on schedule immediately.
The same idiom works with micros() for sub-millisecond timing; it wraps much sooner (roughly 71.6 minutes), which makes the subtraction habit even more important there.
Worked example: blink and button, both responsive
The Arduino IDE ships the canonical demonstration under File > Examples > 02.Digital > BlinkWithoutDelay. Here's the pattern extended so the LED blinks every 500 ms while a button on pin 2 (wired to ground, using the internal pull-up) is polled on every pass:
const uint8_t BUTTON_PIN = 2;
const unsigned long BLINK_INTERVAL = 500;
unsigned long lastBlink = 0;
bool ledOn = false;
int lastButtonReading = HIGH;
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
unsigned long now = millis();
// Task 1: blink
if ((unsigned long)(now - lastBlink) >= BLINK_INTERVAL) {
lastBlink = now;
ledOn = !ledOn;
digitalWrite(LED_BUILTIN, ledOn ? HIGH : LOW);
}
// Task 2: button edge detection, polled every pass
int reading = digitalRead(BUTTON_PIN);
if (reading == LOW && lastButtonReading == HIGH) {
Serial.println(F("pressed"));
}
lastButtonReading = reading;
}Run this in the Arduino IDE (any board, no special permissions needed; select your board and port, then upload). Open the Serial Monitor at 9600 baud and press the button while the LED keeps blinking — every press registers. Now flash a delay(1000)-based blink with the same button code inside the loop and try again: presses during the delay are simply never seen. That side-by-side comparison is the fastest way to internalize why the pattern matters.
Adding more tasks is mechanical: one timestamp variable per interval. An LED at 500 ms, a sensor sample at 2000 ms, a display refresh at 250 ms — three unsigned longs, three if blocks, one loop.
The trade-off, stated plainly
Non-blocking timing buys responsiveness with complexity. Each task carries its own state and timestamp, and anything more elaborate than a toggle tends to grow into an explicit state machine. Accuracy is also bounded by your slowest loop() pass: if one pass takes 12 ms, your 500 ms blink can fire up to 12 ms late. That's jitter, not hard real time. You can quantify it by timestamping each pass with micros() and logging the deltas.
One nuance worth knowing: delay() isn't pure waste everywhere. On ESP8266 and ESP32 cores it yields to background system tasks like WiFi — but it still blocks your own sketch logic, so the responsiveness argument stands.
When to stop polling and use a hardware timer
If a task needs microsecond precision, or must fire even while the main loop is busy with something long, millis() polling is the wrong tool. Hardware timer interrupts fire regardless of what loop() is doing. That's a genuine engineering decision, not a failure of the pattern: polling for human-scale events (buttons, LEDs, display refresh), interrupts for tight deadlines (waveform generation, precise sampling). Keep the ISR minimal — set a flag, let the loop do the work.
Try it this afternoon
Flash the sketch above, then deliberately break it: add a delay(2000) anywhere in the loop and watch both the blink timing and the button detection degrade together. Remove it, and everything recovers. Once you've felt that difference on real hardware, the timestamp-per-task pattern becomes reflex — and the 49-day rollover bug becomes something you catch in code review instead of in the field.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.