Have you ever written Arduino code that counts button presses — and gotten two, three, or even five increments from a single press? You didn't make a mistake in the code. The fault is in the button contact. This phenomenon is called "contact bounce" and its solution is debouncing. In this article, we will look at why it occurs, how to fix it with a software delay, and how to solve it in hardware with a small resistor and capacitor.
What Is Bounce?
A mechanical button does not close its contact cleanly at the moment you press it. The metal parts of the contact bounce off each other for a few microseconds, like a ball dropped on the floor — before settling. During this time (usually 1 to 10 ms, longer for poor-quality buttons), the contact toggles several times between open and closed.
Humans don't notice this — we are too slow. But a microcontroller running at 16 MHz can read the contact a thousand times within those few milliseconds. So it sees every bounce as a separate press.
Example: you press a button once, but the counter increases by three. The code is not wrong — the button simply "reported" three presses.
Button Wiring
First, correct wiring. Connect the button between a digital pin (pin 2 in the example) and ground (GND). Then enable the internal pull-up resistor in code:
// Wiring:
// one side of the button → pin 2
// other side of the button → GND
// Pull-up is enabled in code — no external resistors needed!
const int BUTTON_PIN = 2;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.begin(9600);
}
What the pull-up does: when the button is not pressed, the pin is "pulled" to HIGH (5 V) through the internal resistor. When you press the button, the pin connects to GND — LOW. Without the pull-up resistor, the free pin would be "floating in the air" and read random values.
Hence the rule that confuses beginners at first: a pressed button reads LOW, a released button reads HIGH.
Code Without Debounce — the Problem in Practice
// NAIVE code — with contact bounce!
const int BUTTON_PIN = 2;
int counter = 0;
int previousState = HIGH;
void loop() {
int state = digitalRead(BUTTON_PIN);
// transition from HIGH to LOW = press
if (previousState == HIGH && state == LOW) {
counter++;
Serial.println(counter);
}
previousState = state;
}
This code will increase the counter multiple times per press — every bounce triggers a transition. That's also how the problem shows up: one press, multiple counts.
Software Debounce with millis()
The most common solution is simple: when we detect a state change, we wait a few milliseconds and check whether the state has really stayed. If it has, it's a real press and not a bounce:
// Software debounce with millis()
const int BUTTON_PIN = 2;
int counter = 0;
int lastState = HIGH; // button state
int stableState = HIGH; // confirmed (debounced) state
unsigned long timeOfChange = 0; // when the last change occurred
const unsigned long DEBOUNCE_MS = 20; // settling time
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
int state = digitalRead(BUTTON_PIN);
// state has changed — record the time
if (state != lastState) {
timeOfChange = millis();
lastState = state;
}
// state hasn't changed for long enough — confirm it
if (millis() - timeOfChange >= DEBOUNCE_MS) {
if (lastState != stableState) {
stableState = lastState;
// transition to LOW = confirmed button press
if (stableState == LOW) {
counter++;
Serial.println(counter);
}
}
}
}
Why millis() and not delay()? delay(20) would work for a single button, but it would block the whole program — the microcontroller could do nothing else for 20 ms. With millis(), the code runs without blocking and in the same loop you can also read sensors, update an LCD, and everything else. This is a basic principle that applies to all Arduino programs: never use a long delay() inside the loop() function.
The Bounce2 Library
If you don't want to write the logic yourself, you can use the popular Bounce2 library (found in the Arduino Library Manager). It handles the same thing, and the code is shorter:
// Debounce with the Bounce2 library
#include <Bounce2.h>
const int BUTTON_PIN = 2;
Bounce button = Bounce();
int counter = 0;
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
button.attach(BUTTON_PIN);
button.interval(20); // debounce time in ms
Serial.begin(9600);
}
void loop() {
button.update();
// fell() returns true only on a confirmed press
if (button.fell()) {
counter++;
Serial.println(counter);
}
}
Hardware Debounce
Debouncing can also be done with electrical components — useful with long wires, strong interference, or when the microcontroller must not spend any time on software logic:
The simplest solution is an RC circuit — a 100 nF capacitor between the pin and GND. When the button is not pressed, the capacitor charges and holds the pin firmly HIGH. On a press it discharges, but not instantly — contact bounces are too short to discharge it fully, so the pin transitions smoothly to LOW.
| Method | Advantages | Drawbacks |
|---|---|---|
| Software debounce (millis) | No extra components, adjustable | Uses some program time |
| Bounce2 library | Short and readable code | Dependency on a library |
| RC circuit (capacitor) | Works even with strong interference, no software cost | Requires components, not adjustable |
How Much Time for Debouncing?
Too short an interval won't eliminate all bounces; too long will make pressing feel laggy. Recommended values:
| Contact type | Recommended time |
|---|---|
| Quality tactile button | 5–10 ms |
| Cheap button or relay | 20–50 ms |
| Rotary encoder | 1–5 ms per channel |
The best method: start with 10 ms and observe. If you still get double presses, increase it. If the response feels sluggish, decrease it. In most projects, 20 ms proves to be the sweet spot.
Common Mistakes
- Testing with Serial.println without debouncing: Output printed on every bounce looks like multiple presses — many beginners hunt for the bug in code instead of in the button contact
- Debounce measured from the wrong moment: The debounce time counts from the last change, not the first — the code above respects this
- Long delay() instead of millis(): Works, but blocks the program — with LCDs or sensors it causes new problems
- Forgotten pull-up resistor: Without it, a free pin reads random values and no debouncing will help
- The same debounce for all buttons: Every button has its own bouncing characteristics — use separate time variables for each
- Confusing HIGH/LOW: With INPUT_PULLUP, a pressed button is LOW and a released one is HIGH — the most common logical mistake when counting presses
Conclusion
Debouncing is one of those things that separates a working prototype from a reliable device. The problem is small — a few milliseconds of contact bounce — but its effects are big: phantom presses, wrong counts, and non-reproducible bugs that occur "sometimes." The solution is just as small: twenty milliseconds of patience, measured with millis(). Or a single capacitor. Either way, far cheaper than hours spent hunting a bug that was sitting in the button all along.