Interrupts vs Polling: How Microcontrollers Notice Events

Polling asks a pin again and again whether anything changed; interrupts let the hardware tap the processor when it does. This guide compares both approaches for ESP32 and Arduino, with working code, the five rules that keep interrupt handlers safe, and a clear rule for which mechanism fits each job in a student build.

Written by Projectech11 min readPublished
For Electronics, IoT and embedded students using ESP32 or Arduino who want to stop missing button presses and sensor events in their projects Topics: ESP32, Arduino, Interrupts, Embedded C++
Blueprint-style illustration of a microcontroller board with a push button wired to a processor pin, a large interrupt arrow striking the chip, and a curved polling-loop arrow returning to the pin.
Illustration generated for this guide.
In this guide

Interrupts vs Polling: How Microcontrollers Notice Events

A push button is pressed for 80 milliseconds. A sensor finishes a measurement. A serial byte arrives. In every case, your microcontroller has to notice that something happened — and the way it notices decides how responsive your project feels, how much power it burns, and how much code you have to untangle. The two standard approaches are polling and interrupts, and most student projects use the wrong one at least once before they learn the difference.

This guide compares polling and interrupts on ESP32 and Arduino, shows working code for both, and gives you the timing rules that decide which one fits each job in your build.

The problem: a processor can only do one thing at a time

A microcontroller runs one instruction stream. It cannot literally watch a pin while also updating a display, reading a sensor, and sending data over Wi-Fi. So "noticing an event" always means one of two arrangements: either the main loop keeps asking the pin whether anything changed, or the hardware taps the processor on the shoulder when it does.

Polling is the asking. Your loop reads a digital pin, or checks a flag, on every pass. It is simple, predictable, and easy to debug — which is why almost every beginner sketch starts there. Its cost is that the loop has to come around often enough not to miss the event. A button press that lasts 80 ms is invisible if the loop is busy elsewhere for 200 ms, and a loop that spins checking a pin burns power and blocks everything else.

Interrupts are the tap on the shoulder. You register a small function — an interrupt service routine, or ISR — and tell the hardware which pin change should trigger it. When the event happens, the processor pauses the main loop, runs the ISR, and resumes exactly where it left off. Nothing is missed because of a busy loop, and the processor can sleep between events instead of spinning. The price is discipline: an ISR has strict rules, and breaking them produces bugs that are hard to reproduce.

How polling works, step by step

Polling follows a simple rhythm in the main loop:

  1. Read the input — a digital pin, a sensor-ready flag, or a serial buffer.
  2. Compare it with the previous reading to detect a change.
  3. If something changed, act on it, and record the new state.
  4. Do the rest of the loop's work, then come around again.

Here is the standard polling pattern for a button on an Arduino-style board. The millis() check is the debounce window: a mechanical button bounces for a few milliseconds, and without it one press registers as several.

const int BUTTON_PIN = 2;
int lastState = HIGH;
unsigned long lastChange = 0;

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Serial.begin(115200);
}

void loop() {
  int state = digitalRead(BUTTON_PIN);
  if (state != lastState && millis() - lastChange > 20) {
    lastChange = millis();
    lastState = state;
    if (state == LOW) {
      Serial.println("Button pressed");
    }
  }
  // Other work goes here. Keep it short, or presses get missed.
}

Notice the hidden contract in that last comment. Polling only works if every pass through the loop is short. A delay(500) anywhere, a blocking sensor read, or a slow display update stretches the gap between checks, and short events slip through. That is the classic reason a student project "sometimes misses button presses": the polling loop was fine until something slow was added to it.

Polling also has a power cost that matters on battery projects. A processor that loops forever checking a pin cannot enter deep sleep. On an ESP32 running from a battery, the difference between a polling loop and a sleeping processor waiting for a pin interrupt can be the difference between days and weeks of life.

How interrupts work, step by step

An interrupt inverts the responsibility. Instead of the loop asking, the hardware tells:

  1. You configure a pin as an interrupt source and choose a trigger: RISING, FALLING, CHANGE, or a level on some boards.
  2. You attach a short ISR function to that pin.
  3. When the trigger happens, the processor finishes its current instruction, saves its place, and runs the ISR.
  4. The ISR does the minimum possible work — usually setting a flag or incrementing a counter — and returns.
  5. The main loop notices the flag on its next pass and does the real work.

Here is the same button, handled with an interrupt on an ESP32:

const int BUTTON_PIN = 2;
volatile bool buttonPressed = false;

void IRAM_ATTR onButton() {
  buttonPressed = true;
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN),
                  onButton, FALLING);
  Serial.begin(115200);
}

void loop() {
  if (buttonPressed) {
    buttonPressed = false;
    Serial.println("Button pressed");
  }
  // The loop is free for slow work, and the press is never missed.
}

Three details in that sketch matter more than the rest of the code. First, the flag is volatile, which tells the compiler the value can change outside the normal flow of the loop — without it, the compiler may optimise the flag check away and the loop never sees the press. Second, the ISR does nothing but set the flag: no printing, no delays, no slow calls. Third, the ESP32 version is marked IRAM_ATTR, which keeps the function in fast internal RAM so it can run even when flash access is busy.

On the ESP32, attachInterrupt works on most GPIO pins, but a few pins have boot-time behaviour — GPIO 0, 2, 12 and 15 are the usual suspects — so check the pin notes for your board before wiring a critical interrupt to one of them. On classic Arduino boards, only pins 2 and 3 support external interrupts, which is a common reason a sketch that works on an Uno fails when moved to a different board.

Polling vs interrupts: the honest comparison

Question Polling Interrupts
Who notices the event? The main loop, on its next pass The hardware, immediately
Missed events Possible if the loop is slow Rare, unless ISRs overlap or flood
Power use High: the processor must keep running Low: the processor can sleep between events
Code complexity Low: everything is in one flow Higher: shared flags, volatile, timing rules
Debugging Easy: add prints anywhere Harder: prints inside an ISR break timing
Best for Slow, regular checks; simple sketches Fast, rare, or timing-critical events

The table hides one nuance worth stating plainly: interrupts are not automatically faster at doing the work. The ISR itself should be microseconds long. The real work still happens in the main loop, after the flag is seen. What interrupts buy you is certainty that the event was noticed, and freedom for the loop to do slow work without losing events.

The five rules that keep interrupt code working

Most interrupt bugs come from breaking one of these rules, and each one looks innocent in isolation.

1. Keep the ISR tiny. Set a flag, increment a counter, capture a timestamp with micros(). Nothing else. Printing from an ISR is the most common student mistake: serial output relies on interrupts itself, and on many boards it blocks or corrupts output when called from inside one.

2. Mark shared variables volatile. Any variable written in the ISR and read in the loop must be volatile. For multi-byte values on 8-bit boards, also read them atomically — briefly disable interrupts around the read in the loop, copy the value, and re-enable — or the loop can read a half-updated number.

3. Never delay inside an ISR. delay() depends on a timer interrupt that cannot run while your ISR is executing, so it hangs. The same applies to most library calls that wait: sensor reads, display updates, and network calls all belong in the loop.

4. Debounce in the loop, not the ISR. A bouncing button fires the interrupt several times in a few milliseconds. The clean fix is a timestamp check in the loop: record micros() in the ISR, and ignore presses that arrive within, say, 20 ms of the previous accepted one.

5. Guard against interrupt storms. A noisy or floating input can fire thousands of interrupts per second and starve the main loop entirely. If a pin has no defined level — no pull-up, no pull-down, nothing driving it — fix the wiring before blaming the code. A pull-up resistor and a clean signal prevent an entire class of "my board freezes randomly" reports.

Which one should your project use?

Use polling when the event is slow compared with your loop, when the loop is already short and predictable, or when you are checking something on a schedule anyway — reading a temperature sensor once a second is a polling job, and an interrupt would add complexity for no benefit.

Use interrupts when the event is short, rare, or timing-critical: a button press, a pulse from a flow sensor or encoder, a wake-up signal for a sleeping battery node, or a sensor's data-ready line. A pulse-counting flow meter is the textbook case: at high flow rates the pulses arrive faster than a busy loop can reliably count, and a missed pulse is a measurement error, not a cosmetic glitch.

Many real projects use both, and that is the right answer more often than students expect. A weather station might poll its temperature sensor once a minute and use an interrupt for the rain-gauge tip bucket. A line follower might poll its line sensors in a tight loop and use an interrupt for the emergency-stop button. Matching the mechanism to the event, rather than picking one for the whole project, is the skill this guide is really about.

Common mistakes and how to spot them

  • The press that registers twice, or twenty times. Mechanical bounce, or a CHANGE trigger where a FALLING trigger was intended. Add the loop-side debounce window and check the trigger mode.
  • The flag that never seems to set. A missing volatile qualifier, or the ISR attached to a pin that does not support interrupts on your board. Check both before rewriting the logic.
  • The board that freezes after a while. A slow ISR — usually a print or a delay — or an interrupt storm from a floating pin. Strip the ISR to a flag-set and scope the pin if you can.
  • The counter that reads impossible values. A multi-byte counter read mid-update on an 8-bit board. Copy it atomically in the loop.
  • The interrupt that works on one board and not another. Pin capability differs between boards. Confirm interrupt-capable pins in your board's documentation, not in a tutorial written for a different board.

Frequently asked questions

Can I use interrupts and delays together?
Yes, as long as the delay stays in the main loop. The ISR must stay short; the loop can be as slow as you like, because the interrupt will still fire and set its flag while the loop is delayed.

How many interrupts can I attach?
On the ESP32, most GPIO pins can each carry an interrupt. On classic Arduino boards, only two external interrupt pins exist, so pin choice is a design decision, not an afterthought. Pin-change interrupts are an alternative on some boards, with coarser granularity.

Do interrupts work while the processor sleeps?
On the ESP32, yes — a GPIO interrupt can wake the chip from light sleep and, for RTC-capable pins, from deep sleep. That is the foundation of battery nodes that last months: sleep, wake on an event, handle it, sleep again.

Is an interrupt always better than polling?
No. For slow, scheduled checks, polling is simpler, easier to debug, and entirely adequate. Interrupts earn their complexity only when timing, missed events, or power consumption make polling inadequate.

Why does my ISR work when I add a print but break without one?
A print slows the ISR down enough to mask a race — usually a bounce or a storm — without fixing it. Remove the print, fix the underlying cause with debouncing or pull-ups, and the behaviour becomes stable instead of accidentally stable.

Limitations

This guide covers external pin interrupts, the case most student projects meet. It does not cover timer interrupts, which fire on a schedule rather than a pin change, or the interrupt priority and nesting rules that matter when several sources compete — those deserve their own guide once the basics here are solid. Board-specific details, especially which pins support interrupts and wake-from-sleep, vary; always confirm them against your board's documentation rather than assuming one board's rules apply to another.

More project guides

More in IoT & Embedded