esp32 beginner 20 min

ESP32: read buttons and debounce them in software

Wire a button to an ESP32 and write the debounce code that keeps your state from flickering. Includes the pattern I use in every project.

Code available for: ESP32 ArduinoArduino CMicroPython
Published Aug 9, 2026

A button is the simplest input. It is also the source of more weird bugs than any other component. The button is not “broken.” The button is bouncing, and your code is reading the bounce.

This tutorial covers the wiring, the pull-up resistor (or why you do not need one on the ESP32), and the debounce pattern I copy-paste into every project.

What you need

  • ESP32 dev board
  • A momentary pushbutton (the four-leg tactile switches are fine)
  • A wire, or just the button’s leads

No external resistors needed. The ESP32 has internal pull-ups.

Wiring

Connect one leg of the button to GPIO 4. Connect the other leg to GND.

That is the entire wiring. The internal pull-up (enabled in code) holds the GPIO high when the button is not pressed. When you press the button, the GPIO goes low. When you release, it goes high again.

The bouncing problem

When you press a button, the contacts do not close cleanly. They bounce for a few milliseconds, which means the GPIO sees high-low-high-low-high before settling low. If you read the GPIO every millisecond, you will see multiple presses for one physical press.

You can see this with a quick sketch:

#define BTN_PIN 4

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

void loop() {
  if (digitalRead(BTN_PIN) == LOW) {
    Serial.println("Pressed");
    delay(50);
  }
}

Press the button once. You might see “Pressed” printed 2-4 times. That is the bounce.

The debounce pattern

There are a lot of debounce libraries. I have tried most of them. The one I actually use in production is a state-machine pattern in plain code, because I can read it later and remember what it does.

#define BTN_PIN 4

enum ButtonState { IDLE, PRESSED, RELEASED };
ButtonState state = IDLE;
unsigned long lastChange = 0;
const unsigned long DEBOUNCE_MS = 30;

bool buttonPressed = false;   // set true on press, you clear it

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

void loop() {
  bool reading = digitalRead(BTN_PIN) == LOW;   // LOW = pressed

  switch (state) {
    case IDLE:
      if (reading) {
        state = PRESSED;
        lastChange = millis();
      }
      break;

    case PRESSED:
      if (millis() - lastChange > DEBOUNCE_MS) {
        if (reading) {
          // confirmed press
          buttonPressed = true;
          state = RELEASED;
        } else {
          // it was a bounce, go back to idle
          state = IDLE;
        }
      }
      break;

    case RELEASED:
      if (!reading) {
        // wait for the release to settle
        state = IDLE;
        lastChange = millis();
      }
      break;
  }

  if (buttonPressed) {
    Serial.println("Press detected");
    buttonPressed = false;
  }
}

The pattern:

  • IDLE: waiting for a press. When we see LOW, move to PRESSED and start a timer.
  • PRESSED: waiting for the debounce window to expire. After 30 ms, check the pin again. If still pressed, it was a real press. If not, it was a bounce.
  • RELEASED: waiting for the button to be released. Once it is, go back to IDLE.

30 ms is a good default. Mechanical buttons usually bounce for 5-15 ms. Membrane switches and cheaper buttons can bounce longer.

Edge-triggered vs. level-triggered

The pattern above is edge-triggered: it fires once on the press. Some projects want level-triggered behavior (e.g. “while the button is held, keep doing X”). For that, check the raw reading:

if (digitalRead(BTN_PIN) == LOW) {
  // button is currently being held
}

Multiple buttons

The pattern scales. One state, lastChange, and reading per button:

#define BTN1_PIN 4
#define BTN2_PIN 5
#define BTN3_PIN 18

struct Button {
  int pin;
  bool pressed;
  unsigned long lastChange;
  int state;   // 0=IDLE, 1=PRESSED, 2=RELEASED
};

Button buttons[] = {
  {BTN1_PIN, false, 0, 0},
  {BTN2_PIN, false, 0, 0},
  {BTN3_PIN, false, 0, 0},
};
const int NUM_BUTTONS = 3;

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < NUM_BUTTONS; i++) {
    pinMode(buttons[i].pin, INPUT_PULLUP);
  }
}

void loop() {
  for (int i = 0; i < NUM_BUTTONS; i++) {
    Button &b = buttons[i];
    bool reading = digitalRead(b.pin) == LOW;
    switch (b.state) {
      case 0:
        if (reading) { b.state = 1; b.lastChange = millis(); }
        break;
      case 1:
        if (millis() - b.lastChange > 30) {
          if (reading) { b.pressed = true; b.state = 2; }
          else { b.state = 0; }
        }
        break;
      case 2:
        if (!reading) { b.state = 0; b.lastChange = millis(); }
        break;
    }
    if (b.pressed) {
      Serial.print("Button "); Serial.print(i); Serial.println(" pressed");
      b.pressed = false;
    }
  }
}

Pull-up vs. pull-down

I use pull-up (button to GND) because:

  • The ESP32’s internal pull-ups work fine. Pull-downs are weaker.
  • Wiring is simpler: one GPIO, one GND, no resistor.
  • Pressed = LOW is a common convention, and the code reads more clearly when “pressed” is the active-low state.

When the button is on a long wire

If the button is more than a meter or so from the ESP32, you can get noise on the line. Two fixes:

  1. Add a small ceramic capacitor (100 nF) across the button.
  2. Use shielded cable for the button wire.

For most projects (e.g. a button on a project box), neither is needed. I have used 3 m of unshielded wire for a button and it worked fine.

What to build next

  • A menu system with multiple buttons (up, down, select).
  • A wake-from-sleep button (combine with the deep sleep tutorial).
  • A long-press handler (different actions for short vs. long press).

The wake-from-sleep version is in the book ESP32 Low Power. The menu system is in the book ESP32 UI Patterns.