Button Debouncing: Hardware and Software Methods, Compared

Press once, count four times? Mechanical switch contacts bounce for milliseconds and fast microcontrollers count every bounce. This guide covers the standard millis-based software debounce with code, safe interrupt patterns, RC filters, Schmitt triggers and SR latches, plus a decision table for which method fits your situation.

Written by Projectech9 min readPublished
For B.E./B.Tech electronics, E&TC and computer students building Arduino, ESP32 and embedded projects with push buttons, keypads and rotary controls Topics: Arduino, ESP32, Embedded C
Blueprint-style technical illustration of a push-button switch in exploded view, an oscilloscope waveform showing contact bounce, and a resistor-capacitor filter circuit sketch.
Illustration generated for this guide.
In this guide

Button Debouncing: Hardware and Software Methods, Compared

You press the button once and the counter jumps by four. The menu skips two items per click. The mode changes twice and lands back where it started. If this sounds familiar, you are not dealing with a code bug — you are dealing with physics. Mechanical switch contacts do not close cleanly; they bounce, and your microcontroller is fast enough to count every bounce as a separate press.

This guide explains what bounce is, how to measure it, the standard software fix (with code), the hardware fixes (RC filter, Schmitt trigger, latch), and how to choose between them.

What bounce actually is

A push button is two pieces of metal that slam together when you press. On the millisecond scale, they do not meet once — they collide, rebound, and collide again, typically for 1–20 ms (around 5–10 ms is common for tactile buttons). Each make-and-break is a full logic transition on the pin.

Your loop or interrupt runs at kilohertz to megahertz. A human press lasts 50–100 ms. So during one press, the pin can swing HIGH–LOW–HIGH–LOW a dozen times — and naive code counts every edge. The button is working correctly; your reading method is just faster than the mechanics.

Prove it to yourself (2-minute experiment)

Upload a sketch that increments a counter on every FALLING edge and prints it. Press the button once, firmly. You will usually see 2–6 counts per press, and the number varies press to press — the classic signature of bounce (a wiring fault would be consistent). If you have access to a scope, probe the pin while pressing: you will see the ragged burst of transitions directly. Probing technique is covered in the oscilloscope debugging guide.

Note the bounce time while you are at it — most tactile switches settle within ~10 ms, but cheap or worn switches can bounce for 20 ms or more. Your debounce interval must exceed your actual switch's bounce, not a textbook number.

Software fix 1: the delay (and why to avoid it)

The beginner fix is delay(50) after detecting a press. It works — and it freezes your entire program for 50 ms per press. Sensor readings stall, displays flicker, communication timing breaks. On an ESP32 running WiFi or a robot reading encoders, a blocking delay in the button path is a bug wearing a disguise. Use it never in real code; it is only useful to confirm the diagnosis.

Software fix 2: millis-based debounce (the standard)

The correct software approach: accept a new button state only after the input has been stable for a debounce interval. No blocking, no delays.

const uint8_t BTN_PIN = 2;
const unsigned long DEBOUNCE_MS = 30;

bool lastReading = HIGH;     // raw pin, updated every loop
bool stableState = HIGH;     // debounced state the rest of the code uses
unsigned long lastChangeTime = 0;

void setup() {
  pinMode(BTN_PIN, INPUT_PULLUP);   // button wired pin -> GND
}

void loop() {
  bool reading = digitalRead(BTN_PIN);

  if (reading != lastReading) {
    lastChangeTime = millis();      // input moved: restart the timer
    lastReading = reading;
  }
  // Only adopt the new state after it has been stable long enough
  if ((millis() - lastChangeTime) > DEBOUNCE_MS && reading != stableState) {
    stableState = reading;
    if (stableState == LOW) {
      onButtonPress();              // exactly one call per press
    }
  }
  // ... rest of your non-blocking code runs freely here
}

How it works: every time the raw reading changes, the timer restarts. The debounced state only updates when the reading has sat unchanged for the full interval — the bounces keep resetting the timer, so only the settled state gets through. millis() subtraction is overflow-safe on Arduino. Set DEBOUNCE_MS to roughly 2–3× your measured bounce time; 20–50 ms suits most tactile buttons.

For multiple buttons, keep one small struct (or a tiny class) per button with its own lastReading, stableState, and lastChangeTime — the pattern scales without blocking.

Software fix 3: interrupts, done safely

If the button must wake the MCU from sleep or catch presses during heavy work, use an interrupt — but keep the ISR minimal:

volatile bool pressFlag = false;
volatile unsigned long pressTime = 0;

void IRAM_ATTR onPress() {
  pressTime = millis();   // just record WHEN it happened
  pressFlag = true;
}

void loop() {
  if (pressFlag) {
    pressFlag = false;
    // apply the same stability check here before acting
    if (millis() - pressTime > DEBOUNCE_MS && digitalRead(BTN_PIN) == LOW) {
      onButtonPress();
    }
  }
}

The rules: the ISR records the event and exits — no delay(), no serial prints, no debouncing logic inside it. The debounce decision still happens in the main loop, where time-based checks are safe. (On ESP32, ISRs need IRAM_ATTR; the pattern is the same idea as the Arduino-to-ESP32 migration notes describe for porting interrupt code.)

Hardware fix 1: RC filter

A resistor–capacitor network slows the edge so the bounces never reach the logic threshold as separate transitions: the capacitor charges through the resistor, smoothing the ragged burst into one slow edge. The time constant τ = R × C sets the smoothing — for ~10 ms of debounce, 10 kΩ × 1 µF gives τ = 10 ms.

The catch: the resulting edge is slow, spending a long time in the undefined region between logic LOW and HIGH. Feed that directly into a plain digital input and noise near the threshold can cause multiple transitions — the very problem you tried to fix. So an RC filter should drive a Schmitt-trigger input (next section), not a plain GPIO. Also note the filter adds a small delay of its own (~τ), which is fine for human buttons and fatal for fast signals — never RC-debounce a rotary encoder or a high-speed line.

Hardware fix 2: Schmitt trigger

A Schmitt-trigger gate (the 74HC14 is the classic example) has two different thresholds: one for rising edges, one for falling, with hysteresis between them. Small noise and slow-edge wobble inside the hysteresis band cause no output change at all. The standard robust hardware chain is: button → RC filter → Schmitt trigger → MCU pin. This is the right answer when the button wakes the MCU from deep sleep, where no software is running to debounce.

Hardware fix 3: the SR latch (for SPDT switches)

If your switch is single-pole double-throw (it connects the common terminal to one side or the other, never floating), two NAND/NOR gates wired as an SR latch give bounce-free switching with no timing components at all: the first contact sets the latch, and subsequent bounces on the same contact cannot change it. Elegant, but it only works with SPDT switches — the common 2-pin tactile button is SPST and cannot use this.

Which method to choose

Situation Recommended fix
One or two buttons on a dev board, normal loop code Software millis-debounce
Many buttons or a keypad matrix Software debounce per key in the scan routine
Button wakes MCU from sleep Hardware: RC + Schmitt trigger
Button on a long wire in a noisy environment Hardware RC + Schmitt, plus software as belt-and-braces
Safety input (e-stop style) Hardware latch/conditioning; never software-only
Fast signal (encoder, sensor pulse) Neither — handle in the signal's own domain, not with button debounce

Rule of thumb: software debounce is free and sufficient for human-interface buttons on a running MCU; add hardware when software is not running (sleep) or the environment is hostile.

Five mistakes that survive into demo day

  1. No pull-up or pull-down. A floating input reads random noise as presses. Use the internal pull-up (INPUT_PULLUP) and wire the button to GND — one line of code, zero parts.
  2. Debouncing only the press edge. Release bounces too; the millis pattern above handles both because it debounces the state, not the edge.
  3. Waiting inside the ISR. delay() in an interrupt handler freezes everything at the worst possible moment — and on ESP32 it can trigger the watchdog.
  4. Copy-pasting a 50 ms constant blindly. Measure your switch. A bouncy 20 ms switch with a 10 ms constant still double-counts.
  5. RC filter into a plain GPIO. The slow edge plus a single-threshold input recreates the original problem. RC always pairs with a Schmitt trigger.

FAQ

Why does the internal pull-up matter so much?
Without it the pin floats at an undefined voltage when the button is open, and nearby noise (your hand, mains hum, the WiFi radio) reads as presses. INPUT_PULLUP holds the pin firmly HIGH until the button pulls it LOW. External pull-ups (10 kΩ) do the same job and are needed when the internal one is too weak for long wires.

Can I debounce in hardware AND software?
Yes — they compose cleanly. Hardware handles the electrical reality, software handles the timing decision. For critical inputs this redundancy is good engineering, not overkill.

My keypad still ghost-presses. Is that bounce?
Probably not — matrix keypads need per-key debounce in the scan loop plus correct scanning (one row driven at a time). If adjacent keys trigger together, check your scan timing and diodes before blaming bounce.

Does debouncing add noticeable lag?
The debounce interval (20–50 ms) is far below human perception (~100 ms+). If your UI feels laggy, the cause is elsewhere — blocking code, slow display updates, or radio latency.

Do capacitive touch buttons need debouncing?
They do not bounce mechanically, but the touch-sensing library applies its own filtering and thresholds — which is the same problem in a different costume. Tune the touch threshold rather than adding debounce code.

What about rotary encoders?
Encoders bounce on both channels and encode direction in the phase between them — a button-debounce pattern destroys the quadrature information. Handle encoders with a dedicated state-table or library, not with this guide's methods.

Limitations

This guide covers human-speed mechanical switches — tactile buttons, panel switches, limit switches. It does not cover high-speed signal integrity, mercury-wetted or solid-state switches, or safety-rated input design (which has standards and redundancy requirements far beyond debouncing). Bounce times and the component values here are typical starting points: verify against your actual switch with the two-minute experiment above.

Where to go from here

More project guides

More in IoT & Embedded