Skip to content

ESP32 Buttons in Arduino: Wiring, Debouncing, and Interrupts

Buttons are the simplest human input, but mechanical contacts bounce: they chatter for milliseconds and can look like dozens of edges to fast software. This guide shows correct wiring, polling with debounce and edge detection, and a compact interrupt + flag pattern suitable for busy loop() workloads.

Versions: Examples target Arduino-ESP32 core 2.0.x APIs (attachInterrupt, pinMode, INPUT_PULLUP). If you use 3.x, verify attachInterrupt signature and GPIO restrictions in the release notes for your board.

Wiring: pull-ups, pull-downs, and known idle state

An input must never float; otherwise noise toggles the pin.

  • Internal pull-up (common): one terminal to GPIO, the other to GND. Idle reads HIGH; pressed reads LOW when using INPUT_PULLUP.
  • External pull-down: one terminal to 3.3 V, the other to GPIO, with a ~10 kΩ resistor from GPIO to GND. Idle reads LOW if wired accordingly.

GPIO selection tips

Avoid strapping pins where possible (GPIO 0, 2, 5, 12, 15 on many classic ESP32 modules), because their boot‑time level can alter flash / download behavior. GPIO 13, 14, 25, 26, 27 are commonly convenient for buttons on many devkits—still confirm your module pinout.

Polling with debounce (detects one press event)

This pattern records a stable state after 50 ms quiet time and prints once per transition to pressed.

/*
 * ESP32 button polling + debounce + edge detect
 * Arduino-ESP32 core 2.0.x
 */

const int BUTTON_PIN = 14;

int lastRawReading = HIGH;
int stableState = HIGH;
unsigned long lastDebounceTime = 0;
const unsigned long debounceMs = 50;

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

void loop() {
  const int reading = digitalRead(BUTTON_PIN);

  if (reading != lastRawReading) {
    lastDebounceTime = millis();
  }

  if ((millis() - lastDebounceTime) > debounceMs) {
    if (reading != stableState) {
      stableState = reading;
      if (stableState == LOW) {
        Serial.println("Button pressed (edge)");
      }
    }
  }

  lastRawReading = reading;
}

Interrupts: keep ISRs tiny; debounce in the ISR with care

For low-latency wakeups, use an interrupt to set a flag, then handle serial / network work in loop().

Rules of thumb:

  • Mark ISR-shared data volatile.
  • Keep the ISR short (no delay, no Serial.print, minimal work).
  • On ESP32, avoid millis() inside ISRs; use micros() for simple time gaps.
/*
 * ESP32 button interrupt example (flag + micros debounce)
 * Arduino-ESP32 core 2.0.x
 */

const int BUTTON_PIN = 14;

volatile bool buttonPressed = false;
volatile uint32_t lastIsrUs = 0;
const uint32_t debounceUs = 200000;  // 200 ms (adjust to your switch)

void IRAM_ATTR handleButtonInterrupt() {
  const uint32_t now = micros();
  if (now - lastIsrUs > debounceUs) {
    buttonPressed = true;
    lastIsrUs = now;
  }
}

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

void loop() {
  if (buttonPressed) {
    buttonPressed = false;
    Serial.println("Interrupt: button press accepted");
  }
}

Choosing polling vs interrupts vs libraries

Method Complexity CPU while idle Typical use
Polling + debounce Low Higher (continuous read) Learning, simple UI
Interrupt + flag Medium Very low Busy loop, wake from light sleep
Library (e.g. OneButton) Low–medium Medium Multi-click, long-press gestures

Best practices

  1. Always debounce mechanically—software (millis / micros), hardware (RC), or both.
  2. Prefer INPUT_PULLUP for fewer external parts when polarity fits your schematic.
  3. Keep ISRs to flags/counters; do I/O and state machines in loop().

References