Stop Using delay() in Arduino — Use millis() Like a Pro
After reading this guide, you'll be able to write Arduino code that handles multiple tasks simultaneously — blinking LEDs, reading sensors, and responding to buttons — all without freezing up. No more choosing between a responsive button and a blinking light. You'll ditch delay() forever and replace it with the professional-grade non-blocking timing pattern that every serious maker uses.
Before You Start — Prerequisites, Tools Needed, Safety Notes
You don't need much. But you do need the right mindset: stop thinking about your code as a recipe that runs top-to-bottom. You're about to think in events and timestamps instead.
- Any Arduino board — Uno, Nano, Mega all work identically for this
- Arduino IDE (version
1.8.xor2.x) - Basic familiarity with
void setup()andvoid loop() - At least one LED with a
220Ωresistor to test your output visually - USB cable for uploading sketches
Safety note: No high voltages here. This is pure firmware work. Still, always double-check your resistor values before powering LEDs — a dead LED is a frustrating debugging session you don't need.
Understanding The Basics — Why delay() Is Killing Your Code
delay(1000) tells your Arduino to do absolutely nothing for one full second. It's not waiting politely — it's catatonic. During that second, it can't read a button, update a display, or respond to a sensor. Your entire program is frozen.
millis() is the alternative. It returns the number of milliseconds since the Arduino powered on — a continuously running clock. Instead of pausing the program, you check the clock and ask: "Has enough time passed?" If yes, do the thing. If not, move on and check again next loop cycle.
This is called non-blocking code. Your loop() runs thousands of times per second, and each task only acts when its time is due. Everything else keeps running in parallel. It's the difference between a waiter who stands at your table staring until your food is ready versus one who serves ten tables at once.
The core formula is simple: currentTime - previousTime >= interval. When that condition is true, fire your action and reset previousTime to currentTime.
Step-By-Step Guide — Converting delay() to millis()
- Identify every
delay()in your sketch. Search your code and flag each one. Eachdelay()is a task that needs its own timer variable. - Declare timer variables at the top of your sketch — outside all functions. For each timed task, create an
unsigned long:unsigned long previousMillisLED = 0;. Useunsigned long, notint—millis()returns a large number that overflows a regular integer in 32 seconds. - Set your interval as a constant:
const long intervalLED = 500;— this is your equivalent of the number insidedelay(). - In
void loop(), capture the current time at the very top of the loop:unsigned long currentMillis = millis();Do this once per loop cycle and reuse it everywhere. - Verify by adding a second independent task — a button read or serial print with its own timer. Both should operate without interfering. If they do, your non-blocking conversion is working correctly.
Replace the delay block with a conditional check:
if (currentMillis - previousMillisLED >= intervalLED) {
previousMillisLED = currentMillis;
// Your action here — toggle LED, read sensor, etc.
}Expected result: Your LED blinks at the same rate as before — but your loop continues running freely between blinks.
Pro Tips — What Separates Beginners From Experienced Builders
- Always use
unsigned longfor time variables. The subtractioncurrentMillis - previousMillishandles themillis()rollover (which happens after ~49 days) correctly only with unsigned math. Don't get clever withint— it will bite you. - Never put
delay()inside an interrupt service routine. It doesn't work and will hang your board. If you're using interrupts,millis()is your only friend. - Create a task struct or class for complex projects. When you have five timers, abstract them. A simple struct holding
previousMillis,interval, and a function pointer keeps your code clean and scalable. - Use
micros()for precision timing under 1ms.millis()has ~1ms resolution. For servo control or high-speed PWM logic, switch tomicros()— same pattern, higher precision. - Keep your loop body fast. Non-blocking code only works if nothing else inside
loop()is slow. AvoidSerial.println()on every cycle — buffer your outputs or throttle them with their own timer.
Frequently Asked Questions
Q: Can I ever use delay() anymore?
Yes — in setup, for one-time initialization sequences where blocking is acceptable. Once your main loop is running, cut it out entirely.
Q: What happens when millis() overflows after 49 days?
Nothing bad, as long as you use unsigned long variables and subtraction (not comparison with absolute values). The math wraps correctly. This is a non-issue in practice.
Q: My timing seems slightly off. What's wrong?
Your loop body probably has a slow operation — a blocking Serial call, a slow library function, or leftover delay() somewhere. Profile each section and eliminate the bottleneck.
Q: Does this work with libraries that use delay() internally?
Some libraries are poorly written and block internally. Check the library source. If it uses delay(), look for an async alternative or rewrite the relevant portion. This is a real issue with some LCD and sensor libraries.