How to Use Interrupts on Arduino and Why delay() Is Holding You Back

Using hardware interrupts on Arduino instead of delay for responsive, non-blocking code

You’ve got a sketch that blinks an LED and reads a button. You upload it, and it works. Then you hold the button during that 1-second blink pause and… nothing happens. Press again. Nothing. Mash it. Still nothing. The LED blinks on its own schedule, completely ignoring you.

That delay(1000) call you copied from every beginner tutorial is the problem. And it’s probably hiding in every sketch you’ve ever written.

By the end of this article, you’ll know two better approaches, millis() and hardware interrupts, with working code for the Arduino UNO R4 Minima. You’ll be able to rip out delay() and build sketches that actually respond to the real world.

What delay() Actually Does to Your CPU

delay() doesn’t pause your program gracefully. It locks your processor in a busy-wait loop. The CPU sits there, counting clock cycles, doing absolutely nothing else. Your entire loop() function freezes.

Think of it like closing your eyes for 1 second out of every 2. You miss half of everything.

Timeline (ms):  0----500----1000----1500----2000
                |==DELAY==|         |==DELAY==|
loop() active:  ▓▓▓▓▓▓▓▓▓▓░░░░░░░░▓▓▓▓▓▓▓▓▓▓░░░░░░░░
Button press:              ^ MISSED!
                           (occurred during delay)

The consequences go beyond missed button presses. You can’t read a sensor while a delay() is running. You can’t update a display or handle serial commands. And if you need two things to happen at different intervals (say, blink an LED every 500 ms and check a temperature sensor every 2000 ms), delay() turns that into a tangled mess.

Non-Blocking Code with millis(): Your New Default

The millis() function returns the number of milliseconds since your board powered on. It keeps ticking regardless of what your code is doing. Instead of pausing, you check whether enough time has passed since the last action.

Here’s the pattern:

  ┌──────────── loop() runs continuously ────────────┐
  │                                                    │
  │  Is it time to toggle LED?                         │
  │    YES -> toggle, record new timestamp             │
  │    NO  -> skip                                     │
  │                                                    │
  │  Is button pressed?                                │
  │    YES -> respond immediately                      │
  │    NO  -> skip                                     │
  │                                                    │
  └──── returns to top of loop() instantly ────────────┘

And here’s a complete sketch you can upload right now:

// Blink LED + read button, no delay() anywhere
// Arduino UNO R4 Minima (works on most Arduino boards)

const int ledPin = 13;
const int buttonPin = 2;

unsigned long previousMillis = 0;   // last time LED toggled
const long interval = 1000;         // blink interval in ms
int ledState = LOW;

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);  // uses internal pullup resistor
  Serial.begin(9600);
}

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

  // --- LED blink task ---
  if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;       // save the timestamp
    ledState = (ledState == LOW) ? HIGH : LOW;
    digitalWrite(ledPin, ledState);
  }

  // --- Button read task ---
  if (digitalRead(buttonPin) == LOW) {    // LOW when pressed (pullup wiring)
    Serial.println("Button pressed!");
  }
}

The loop() spins thousands of times per second. Each pass, it checks: has 1000 ms elapsed? If yes, toggle the LED. If no, skip ahead and check the button. The button check runs on every single pass, so you’ll never miss a press.

This millis() pattern should replace delay() as your default habit. It’s the simplest arduino delay alternative, and it solves 80% of the timing problems beginners hit.

But there’s a limitation. You’re still polling, checking the button on every pass through loop(). For a button, that’s fine. For a fast signal that arrives at an unpredictable moment (a sensor pulse, an encoder tick), polling might not be fast enough. That’s where interrupts come in.

External Interrupts: Let the Hardware Do the Watching

An interrupt is a hardware tap on the CPU’s shoulder. Instead of your code constantly asking “did something happen?”, the hardware tells the CPU the instant something happens. The CPU drops what it’s doing, runs a special function you’ve defined (called an ISR, or Interrupt Service Routine), then picks up exactly where it left off.

External Interrupt Flow:

  Normal code running ──────────────────>
                            |
                    Pin event fires!
                            |
                            v
                  ┌──────────────────┐
                  │   ISR executes   │
                  │  (toggle flag)   │
                  └────────┬─────────┘
                           |
                           v
  Normal code resumes ──────────────────>

It’s the difference between checking your front door every 5 minutes versus installing a doorbell.

A big win for the UNO R4 Minima: the older UNO R3 only supported external interrupts on pins 2 and 3. The R4 Minima (built on the Renesas RA4M1 processor) supports attachInterrupt() on every digital pin. That’s a meaningful upgrade when you’re wiring up a real project.

Here’s a complete arduino interrupt example. Button on pin 2, LED on pin 13:

// External interrupt: button toggles LED instantly
// Arduino UNO R4 Minima

const int ledPin = 13;
const int buttonPin = 2;

volatile bool ledState = false;   // 'volatile' because ISR modifies it

void setup() {
  pinMode(ledPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);

  // Fire ISR on FALLING edge (button press pulls pin LOW)
  attachInterrupt(digitalPinToInterrupt(buttonPin), buttonISR, FALLING);
}

void loop() {
  // loop() is free to do other work
  // LED state is updated by the ISR automatically
  digitalWrite(ledPin, ledState);
}

// ISR: runs instantly when pin 2 goes LOW
void buttonISR() {
  ledState = !ledState;    // toggle
}

A few things to notice:

The volatile keyword on ledState is critical. It prevents the compiler from caching the value in a register and ignoring future changes. Because the ISR can modify ledState at any moment, the compiler needs to re-read it from memory every time loop() references it. Without volatile, your loop() might never see the ISR’s changes.

The FALLING mode triggers when the pin transitions from HIGH to LOW, which is exactly what happens when you press a button wired with a pullup resistor. Other modes are RISING, CHANGE, LOW, and HIGH. For a typical button, stick with FALLING.

The ISR itself is tiny: one line. That’s intentional. I’ll explain why in a minute.

(You’ll probably notice some bounce: the LED might flicker on a press. That’s a debouncing problem, not an interrupt problem. A small capacitor or a software debounce timer fixes it.)

Timer Interrupts: Precision Without Polling

External interrupts react to pin events. But what if you need something to happen at an exact interval, like sampling a sensor every 10 ms for consistent data? You can’t trust loop() timing for that; its cycle time varies depending on what code runs each pass.

Timer interrupts solve this. A hardware timer counts clock cycles in the background and fires an ISR at whatever interval you’ve configured.

On the UNO R4 Minima, the Renesas Arduino core includes the FspTimer library for this. Here’s a working sketch that samples an analog pin every 10 ms using a timer interrupt:

// Timer interrupt: sample analog sensor every 10 ms
// Arduino UNO R4 Minima (Renesas core with FspTimer)

#include "FspTimer.h"

FspTimer sampleTimer;

volatile bool sampleReady = false;    // flag set by ISR
volatile int sensorValue = 0;         // raw reading from ISR

const int sensorPin = A0;

// Timer ISR: fires every 10 ms
void timerISR(timer_callback_args_t __attribute((unused)) *args) {
  sensorValue = analogRead(sensorPin);  // quick read
  sampleReady = true;                    // tell loop() data is ready
}

void setup() {
  Serial.begin(9600);
  pinMode(sensorPin, INPUT);

  // Get an available timer
  uint8_t timerType = GPT_TIMER;
  int8_t timerIndex = FspTimer::get_available_timer(timerType);

  sampleTimer.begin(TIMER_MODE_PERIODIC, timerType, timerIndex,
                    100.0f,   // 100 Hz = every 10 ms
                    0.0f, timerISR);
  sampleTimer.setup_overflow_irq();
  sampleTimer.open();
  sampleTimer.start();
}

void loop() {
  if (sampleReady) {
    sampleReady = false;
    Serial.print("Sensor: ");
    Serial.println(sensorValue);
    // Do filtering, averaging, or other heavy processing here
  }

  // loop() is free to handle other tasks between samples
}

The key pattern: the ISR sets a flag, and loop() does the heavy work. The ISR grabs the reading and gets out fast. loop() handles the printing and any processing. This flag-based handoff is the standard best practice for arduino timer interrupt code.

(A note on analogRead() inside an ISR: on the RA4M1, this is fast enough for a 10 ms interval. On slower boards or with shorter intervals, analogRead() may take too long inside the ISR. Profile before assuming.)

Arduino ISR Rules You Should Tattoo on Your Arm

ISRs pause normal execution. While your ISR runs, no other interrupt gets serviced and loop() is frozen. A long ISR causes exactly the same problem delay() does. Keep them short.

ISR Rules of Thumb:
┌───────────────────────────────────────────────┐
│  DO                    │  DON'T               │
│────────────────────────│───────────────────────│
│  Keep it short         │  Use delay()          │
│  Use volatile vars     │  Call Serial.print()  │
│  Set flags             │  Do heavy math        │
│  Toggle simple I/O     │  Call millis()        │
│  Return quickly        │  Use blocking libs    │
└───────────────────────────────────────────────┘

Why these rules? delay() depends on interrupts internally, so calling it inside an ISR creates a deadlock. Serial.print() uses a buffer that also relies on interrupts. millis() stops updating while interrupts are disabled. Heavy computation just takes too long.

The golden rule: get in, set a flag or flip a pin, get out. Let loop() handle everything else.

Picking the Right Tool for Your Problem

You don’t have to choose just one technique. Real projects often combine all three. Here’s how to decide:

What are you trying to do?
│
├─ "I just need to avoid blocking"
│   └──> Use millis()  (Section 3)
│
├─ "I need instant response to a pin event"
│   └──> Use external interrupt  (Section 4)
│
├─ "I need precise, periodic timing"
│   └──> Use timer interrupt  (Section 5)
│
└─ "I need all of the above"
    └──> Combine them. They work together.

A practical example: use a timer interrupt to sample a sensor at exactly 100 Hz. Use an external interrupt to catch a button press that starts and stops data logging. Then in loop(), use millis() to update a display every 250 ms without blocking. Each technique handles what it does best.

Replace delay() with Arduino Interrupts in Your Next Project

Here’s the progression. delay() blocks everything. millis() frees your loop to multitask. External interrupts let hardware notify you instantly when a pin changes, and timer interrupts give you precise, clock-driven timing without polling.

Go open one of your existing sketches right now. Find a delay() call and replace it with the millis() pattern. Once that feels natural, add an interrupt for your next button or sensor input.

If you want to take the next step, look into state machines for managing complex behavior across multiple tasks, and firmware debouncing to clean up those bouncy button signals from your interrupt examples. That’s where things start to feel like real embedded programming.


Hubble Network connects your Arduino sensors to the cloud via Bluetooth-to-satellite—no gateways, no cellular, no line of sight required. See how it works →