Phase 3: GPIO & Hardware 13 min read

Debouncing Buttons on ESP32: Software and Hardware Methods

Fix button bounce on ESP32 — learn software debouncing with millis(), the Bounce2 library, hardware RC debounce circuits, and interrupt-safe debouncing.

Updated June 19, 2026

What is Button Bounce?

When you press a mechanical push button, the metal contacts do not make a clean, single connection. Instead, they bounce open and closed multiple times within the first few milliseconds due to their physical elasticity. A high-speed oscilloscope will show 5–50 transitions within the first 5–50 ms of a button press.

This is completely invisible to a human finger, but ESP32 running at 240 MHz can read each bounce as a separate event. Press a button once and your counter may jump by 3 or 7. This is button bounce — and debouncing is the solution.

Visualising Bounce on the Serial Monitor

Arduino (C++) — Shows raw bouncing
#define BUTTON_PIN 16

int pressCount = 0;
bool lastState = HIGH;

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Serial.println("Counting raw presses (no debounce):");
}

void loop() {
  bool current = digitalRead(BUTTON_PIN);
  if (lastState == HIGH && current == LOW) {  // Falling edge
    pressCount++;
    Serial.printf("Press #%d detectedn", pressCount);
  }
  lastState = current;
  // No delay — catches every bounce
}

Press the button once. You will likely see 2–6 “presses” registered. Run this first to confirm bounce is the problem, then apply a fix.

Method 1: Simple Delay Debounce

The naïve fix: after detecting a press, wait 50 ms and then continue. Simple but blocks the loop:

Arduino (C++)
#define BUTTON_PIN 16
#define DEBOUNCE_MS 50

int count = 0;
bool lastState = HIGH;

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

void loop() {
  bool current = digitalRead(BUTTON_PIN);
  if (lastState == HIGH && current == LOW) {
    delay(DEBOUNCE_MS);                         // Wait for bounce to settle
    if (digitalRead(BUTTON_PIN) == LOW) {       // Still pressed after wait?
      count++;
      Serial.printf("Valid press #%dn", count);
    }
  }
  lastState = current;
}

This works but the 50 ms blocking period means the MCU cannot do anything else — bad for Wi-Fi, sensor polling, or OLED updates happening in the same loop.

Method 2: Non-Blocking millis() Debounce (Recommended)

The millis() method records the time of the last state change and only accepts a new state after the debounce window has elapsed. The loop remains unblocked:

Arduino (C++)
#define BUTTON_PIN  16
#define LED_PIN      2
#define DEBOUNCE_MS 50

bool buttonState    = HIGH;   // Current debounced state
bool lastRawState   = HIGH;   // Last raw reading
unsigned long lastChangeTime = 0;
int pressCount = 0;

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

void loop() {
  bool rawReading = digitalRead(BUTTON_PIN);

  if (rawReading != lastRawState) {       // State changed
    lastChangeTime = millis();            // Reset timer
    lastRawState   = rawReading;
  }

  if ((millis() - lastChangeTime) > DEBOUNCE_MS) {
    if (rawReading != buttonState) {      // Stable new state
      buttonState = rawReading;
      if (buttonState == LOW) {           // Debounced press event
        pressCount++;
        Serial.printf("Debounced press #%dn", pressCount);
        digitalWrite(LED_PIN, !digitalRead(LED_PIN));
      }
    }
  }

  // Loop free to do other work here — no blocking
}

Method 3: The Bounce2 Library

The Bounce2 library encapsulates debouncing logic cleanly. Install it from the Arduino Library Manager (Sketch → Include Library → Manage Libraries → search “Bounce2”).

Arduino (C++) — Bounce2 library
#include <Bounce2.h>

#define BUTTON_PIN 16
#define LED_PIN     2

Bounce debouncer;   // One instance per button

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  debouncer.attach(BUTTON_PIN, INPUT_PULLUP);
  debouncer.interval(25);   // 25 ms debounce window
}

void loop() {
  debouncer.update();   // Must call every loop iteration

  if (debouncer.fell()) {     // Falling edge = button pressed
    Serial.println("Button fell (pressed)");
    digitalWrite(LED_PIN, !digitalRead(LED_PIN));
  }
  if (debouncer.rose()) {     // Rising edge = button released
    Serial.println("Button rose (released)");
  }
}

Multiple Buttons with Bounce2

Arduino (C++)
#include <Bounce2.h>

const int BTNS[] = {16, 17, 18};
const int LEDS[] = { 2, 13, 14};
const int N      = 3;
Bounce debouncers[N];

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < N; i++) {
    debouncers[i].attach(BTNS[i], INPUT_PULLUP);
    debouncers[i].interval(25);
    pinMode(LEDS[i], OUTPUT);
  }
}

void loop() {
  for (int i = 0; i < N; i++) {
    debouncers[i].update();
    if (debouncers[i].fell()) {
      Serial.printf("Button %d pressedn", i + 1);
      digitalWrite(LEDS[i], !digitalRead(LEDS[i]));
    }
  }
}

Method 4: Hardware RC Debounce Circuit

Hardware debouncing uses a resistor-capacitor (RC) filter to slow down the signal edge, smearing the bounces into a single clean transition:

RC Debounce Circuit
3.3V ──── 10kΩ (pull-up) ──── GPIO pin
                               |
                            100nF cap
                               |
Button ────────────────────── GND

Time constant τ = R × C = 10,000 × 0.0000001 = 1 ms

Bounce spikes <1ms are smoothed. Only sustained state change passes through.
No software debounce needed — use plain INPUT mode in code.
Method Complexity Blocks Loop? Best For
delay() debounce Simplest Yes Quick prototypes
millis() debounce Medium No Most projects
Bounce2 library Medium No Multiple buttons
Hardware RC Hardware No Noisy environments

Interrupt-Safe Debouncing

Arduino (C++)
#define BUTTON_PIN  16
#define DEBOUNCE_US 50000   // 50 ms in microseconds

volatile unsigned long lastISRTime = 0;
volatile int pressCnt = 0;

void IRAM_ATTR onPress() {
  unsigned long now = micros();
  if (now - lastISRTime > DEBOUNCE_US) {
    pressCnt++;
    lastISRTime = now;
  }
}

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

void loop() {
  static int last = 0;
  if (pressCnt != last) {
    last = pressCnt;
    Serial.printf("Interrupt count (debounced): %dn", pressCnt);
  }
}

Choosing the Right Debounce Time

Switch Type Recommended Debounce
Cheap tactile button 50 ms
Quality tactile switch 20–25 ms
Membrane keypad 5–10 ms
Reed switch 50–100 ms
Limit switch / microswitch 20–50 ms
Capacitive touch sensor None needed

Summary

Button bounce causes one physical press to register as multiple software events because mechanical contacts oscillate rapidly. For most projects, the non-blocking millis() method (50 ms window) or the Bounce2 library are the best solutions — clean, reliable, and loop-friendly. Hardware RC debounce is ideal when code simplicity matters or for input-only GPIO34–GPIO39 pins where you want zero software overhead.

Frequently Asked Questions

Button bounce is the rapid make-and-break of mechanical contacts when a switch is pressed or released. A button can bounce 5–50 times in the first 5–50 ms, and ESP32 reads each bounce as a separate press event. For a counter or toggle, this means one press registers as multiple events.
Add delay(50) after detecting a button press and before reading the state again. While this works, it blocks the main loop for 50 ms. The millis() debounce method is better because it is non-blocking.
Record the time (millis()) when the button state first changes. Only accept the change as valid if the button has been in the new state for longer than the debounce threshold (typically 20–50 ms). This prevents counting rapid bounces as separate events.
Bounce2 is a lightweight Arduino library that handles button debouncing transparently. Install it via Arduino Library Manager, include Bounce2.h, create a Bounce object per button, call update() in loop(), then use rose() for a rising edge and fell() for a falling edge.
Hardware debounce uses an RC filter (resistor + capacitor) to slow down the signal transition, smoothing out the bounce. A 10 kΩ resistor and 100 nF capacitor give a time constant of 1 ms, which is enough for most switches. Schmitt-trigger inputs (like the 74HC14) can further clean the signal.
For most tactile push buttons, 20–50 ms is sufficient. Cheap buttons or microswitches may need up to 100 ms. Membrane keyboards often need only 5–10 ms. If in doubt, start with 50 ms and reduce if input feels sluggish.
Yes. The safest method is to trigger the interrupt on the falling edge, record the time in the ISR, and ignore subsequent interrupts that arrive within the debounce window. A volatile timestamp variable shared between ISR and main loop handles this.
Rotary encoders require much faster debounce logic (microseconds rather than milliseconds) and it is better to use hardware debounce (RC filter + Schmitt trigger) or a dedicated encoder IC. Software millis() debounce is too slow for fast encoder rotation.
Use an array of Debouncer objects from the Bounce2 library, or maintain a separate lastTime[] and lastState[] array per button and apply the same millis() logic to each. Both methods scale cleanly to 4, 8, or more buttons.
Yes — if using the button as a manual signal where the user holds it down and you only care about the held state (not the press event), debouncing adds unnecessary delay. Also for capacitive touch sensors, which have no mechanical bounce.

Projects to Build

Put this knowledge to work — try one of these hands-on projects.