The most reliable Arduino code for push button inputs uses a non-blocking millis() timer to debounce the mechanical contacts, ignoring the 5-50ms electrical noise that occurs when the switch closes. Relying on delay() for debouncing halts your microcontroller's main loop, causing missed sensor readings and sluggish UI responses. By tracking state changes with timestamps and utilizing the ATmega328P's internal pull-up resistors, you can achieve rock-solid button detection without external components or blocking code.

Project Spec Sheet & Parts List

Before writing a single line of code, you need to understand the physical limitations of your hardware. Mechanical switches do not transition cleanly from open to closed; the metal contacts physically bounce against each other, creating rapid micro-second voltage spikes. Below is the exact hardware specification for a robust, beginner-to-intermediate push button circuit.

Component Model / Variant Critical Specification Est. Price (2026)
Microcontroller Arduino Uno R3 (ATmega328P) 16 MHz clock, 20mA max GPIO sink/source $27.00 (Official) / $14.00 (Clone)
Tactile Switch 6x6x5mm SPST Momentary Bounce time: 1-5ms typical (up to 50ms cheap) $0.05 per unit (bulk)
Pull-Down Resistor 10kΩ Carbon Film (1/4W) Required only if NOT using internal pull-ups $0.02 per unit
Debounce Capacitor 0.1µF Ceramic (104) Hardware debounce filter (optional) $0.03 per unit
Current Limiting Resistor 220Ω or 330Ω (for indicator LED) Keeps LED current under 15mA $0.02 per unit
💡 Bench Tip: Never trust the "5ms bounce time" listed on cheap datasheets. I routinely see 15-30ms of bounce on generic tactile switches sourced from overseas marketplaces. Always set your software debounce window to at least 50ms to guarantee a clean read.

Wiring the Push Button: Internal Pull-Up vs External Pull-Down

A floating pin is the number one cause of erratic button reads. If a microcontroller GPIO pin is not tied to a known voltage (VCC or GND), it acts as an antenna, picking up electromagnetic interference from your body, nearby wires, and Wi-Fi routers. You have two ways to solve this: external pull-down resistors or the Arduino's internal pull-up resistors.

For 95% of hobbyist and prototyping builds, using the internal pull-up resistor (INPUT_PULLUP) is the superior choice. It eliminates the need for an external 10kΩ resistor, reduces breadboard clutter, and simplifies wiring. The only trade-off is inverted logic: the pin reads HIGH when unpressed and LOW when pressed.

Pin Mapping Table

Arduino Uno R3 Pin Component Connection Wiring Mode Logic State (Unpressed)
Digital Pin 2 Switch Leg 1 (NO) Internal Pull-Up (INPUT_PULLUP) HIGH (5V)
GND Switch Leg 2 (NO) Common Ground N/A
Digital Pin 13 LED Anode (via 220Ω resistor) Output (OUTPUT) LOW (0V)
GND LED Cathode Common Ground N/A

Reference: For a deeper dive into how the ATmega328P handles internal resistors, consult the official Arduino digital pins documentation.

The Complete Arduino Code for Push Button

Below is the production-ready, non-blocking Arduino code for push button debouncing. This sketch avoids delay() entirely, allowing your main loop to continue executing other tasks (like reading sensors or updating displays) while waiting for the switch contacts to settle.


/*
 * Reliable Non-Blocking Push Button Debounce
 * Target Board: Arduino Uno R3 / Nano v3 (ATmega328P)
 * Wiring: Switch between Pin 2 and GND (using INPUT_PULLUP)
 */

// --- Pin Definitions ---
#define BUTTON_PIN 2
#define LED_PIN 13

// --- Debounce Configuration ---
const unsigned long DEBOUNCE_DELAY = 50; // 50ms debounce window

// --- State Variables ---
int currentButtonState = HIGH;   // Current debounced state
int lastButtonState = HIGH;      // Previous debounced state
int rawReading;                  // Raw read from digitalRead()

unsigned long lastDebounceTime = 0;  // Timestamp of last state change

void setup() {
  // Configure pins
  pinMode(BUTTON_PIN, INPUT_PULLUP); // Enables internal 20k-50k pull-up resistor
  pinMode(LED_PIN, OUTPUT);
  
  // Initialize serial for debugging
  Serial.begin(115200);
  Serial.println("System Ready. Waiting for button press...");
  
  // Ensure LED starts off
  digitalWrite(LED_PIN, LOW);
}

void loop() {
  // 1. Read the raw state of the switch
  rawReading = digitalRead(BUTTON_PIN);

  // 2. Check if the raw reading differs from the last known raw state
  if (rawReading != lastButtonState) {
    // Reset the debouncing timer
    lastDebounceTime = millis();
  }

  // 3. If the reading has exceeded the debounce delay, lock in the state
  if ((millis() - lastDebounceTime) > DEBOUNCE_DELAY) {
    // If the button state has actually changed:
    if (rawReading != currentButtonState) {
      currentButtonState = rawReading;

      // --- Action on State Change ---
      // Because we are using INPUT_PULLUP, LOW means pressed.
      if (currentButtonState == LOW) {
        Serial.println("[EVENT] Button Pressed!");
        digitalWrite(LED_PIN, HIGH); // Turn LED ON
      } else {
        Serial.println("[EVENT] Button Released.");
        digitalWrite(LED_PIN, LOW);  // Turn LED OFF
      }
    }
  }

  // 4. Save the raw reading for the next loop iteration
  lastButtonState = rawReading;
}

Debugging: Fixing Floating Pins and Compile Errors

When working with GPIO inputs, things rarely work perfectly on the first upload. Here is how to diagnose the most common hardware and software failures associated with push button circuits.

Compile Error: Scope and Declaration Issues

If you copy-pasted a snippet from a forum without the header definitions, the IDE will throw this exact error string:

error: 'BUTTON_PIN' was not declared in this scope

The Fix: Ensure your #define BUTTON_PIN 2 and #define LED_PIN 13 macros are placed at the very top of your sketch, outside of setup() and loop(). Alternatively, declare them as const int BUTTON_PIN = 2;.

Runtime Symptom: Erratic Serial Output (The Floating Pin)

Symptom: The Serial Monitor spams [EVENT] Button Pressed! and [EVENT] Button Released. hundreds of times per second without you touching the breadboard, or the LED flickers dimly.

The First 3 Things to Check:

  1. Pull-Up/Pull-Down Presence: Did you use pinMode(BUTTON_PIN, INPUT); without an external 10kΩ resistor to ground? Change it to INPUT_PULLUP in the code, or wire the physical 10kΩ resistor.
  2. Switch Orientation: A 6x6mm tactile switch has four legs. Legs on the same side are internally shorted. If you wired both legs on the same side of the breadboard ditch, the circuit is permanently closed. Always wire diagonally across the center ditch.
  3. Ground Continuity: Set your multimeter to continuity mode. Place one probe on the Arduino GND pin and the other on the switch leg connected to ground. It should beep. If it doesn't, your breadboard ground rail is disconnected from the Arduino.

Ranked Causes for Missed or Double Presses

Rank Cause Diagnostic Measurement Solution
1 Insufficient Debounce Time Oscilloscope shows 15ms of bounce spikes. Increase DEBOUNCE_DELAY to 50ms or 75ms.
2 Switch Contact Oxidation Multimeter reads > 5Ω when switch is pressed. Replace the tactile switch; internal contacts are worn out.
3 EMI / Noise Injection Reads change when a nearby relay or motor switches. Add a 0.1µF ceramic capacitor in parallel with the switch.
4 Breadboard Contact Fatigue Wiggling the switch leg changes the state. Move to a different breadboard row or solder the prototype.

For a comprehensive look at the physics of switch bounce and hardware filtering, review the All About Circuits guide on dealing with switch bounce.

Extending and Simplifying the Build

While writing your own non-blocking debounce logic is an excellent way to understand microcontroller timing, production firmware often relies on proven libraries to handle edge cases like long-presses, double-clicks, and multi-button matrices.

Simplifying with the Bounce2 Library

If your project involves more than two buttons, managing individual millis() timestamps for each pin becomes tedious. The Bounce2 library (available via the Arduino Library Manager) abstracts the timing math. After installing it, your loop shrinks dramatically:


#include 

Bounce button1 = Bounce();

void setup() {
  pinMode(2, INPUT_PULLUP);
  button1.attach(2);
  button1.interval(50); // 50ms debounce
}

void loop() {
  button1.update();
  if (button1.fell()) { // 'fell' means transitioned from HIGH to LOW
    Serial.println("Button 1 Pressed!");
  }
}

Extending to Hardware Interrupts

If your main loop is heavily burdened with tasks like driving WS2812B LED strips or processing high-speed UART data, polling digitalRead() every loop iteration might result in missed button presses. You can extend this build by attaching the button to an interrupt-capable pin (Pins 2 and 3 on the Uno R3) using attachInterrupt().

However, never run debounce logic inside an Interrupt Service Routine (ISR). ISRs must execute in microseconds. Instead, use the ISR merely to set a volatile boolean flag, and let your main loop handle the 50ms debounce timing and state actions. For industrial environments where electrical noise is severe, bypass software debouncing entirely and use a hardware 74HC14 Schmitt Trigger combined with an RC low-pass filter to guarantee a clean, single digital edge to the microcontroller.