Articles › Microcontrollers and code

Reliable buttons on an ESP32: pull-ups and debounce with millis()

Why a floating input reads garbage, pull-up vs pull-down, how long switches bounce, a millis() debounce sketch, and the strapping pins to keep buttons off.

Beginner 6 min read October 22, 2026 Maker electronics for beginners · lesson 7/10

What you will learn
  • An input pin connected to nothing floats and reads HIGH or LOW at random. It always needs a pull-up or pull-down.
  • INPUT_PULLUP with the button wired to GND is the simplest setup, but the logic is inverted: pressed reads LOW.
  • Switch contacts bounce for about 1.6 ms on average, but some take far longer. Use a 20–50 ms debounce time.
  • On the original ESP32, avoid GPIO0, 2, 5, 12 and 15, and remember GPIO34–39 have no internal pull-ups. Other chips: check their datasheet.

A button looks like the simplest part in any circuit, yet wired straight in it usually shows two faults: it reads random values when nobody touches it, and one press counts several times. The first is fixed with a pull-up or pull-down resistor, the second with debouncing. This article covers both, with code and a list of pins to keep buttons off.

The examples use an ESP32 DevKit (ESP32-WROOM-32), Arduino IDE 2 and the arduino-esp32 core.

Floating pins: why it reads garbage

A GPIO set as an input has a very high resistance. Connected to nothing, its voltage is set by whatever noise is around, such as a hand coming close or a neighbouring wire. The program reads HIGH, then LOW, then HIGH. That state is called floating.

The fix is a resistor that gives the pin a definite default:

  • Pull-up: resistor from the pin to 3.3 V, button from the pin to GND. Not pressed = HIGH, pressed = LOW.
  • Pull-down: resistor from the pin to GND, button from the pin to 3.3 V. Not pressed = LOW, pressed = HIGH.

The ESP32 has pull-up and pull-down resistors built in. The arduino-esp32 docs give them as about 45 kΩ, enabled with pinMode().

ModeExternal resistor neededNot pressed readsPressed readsWhen to use
INPUTYes (or it floats)Depends on circuitDepends on circuitPins 34–39, or long wires where you want a lower resistance
INPUT_PULLUPNoHIGHLOWThe usual choice, button to GND
INPUT_PULLDOWNNoLOWHIGHWhen you want pressed to read HIGH

Current through the button

When pressed, current flows from 3.3 V through the pull-up to GND. By Ohm’s law:

  • Internal pull-up of about 45 kΩ: 3.3 ÷ 45,000 ≈ 0.073 mA
  • External 10 kΩ: 3.3 ÷ 10,000 = 0.33 mA

Both are tiny and make no difference to a battery. For a button on the board with short leads, the internal pull-up is enough. If the button sits at the end of tens of centimetres of wire and picks up noise, switch to a lower external value such as 10 kΩ.

How long a button bounces

The metal contacts inside a button don’t touch once and settle. They strike and spring apart several times before they stay closed. A microcontroller reads fast enough to see every one of those, so it counts several presses.

Jack Ganssle measured the bounce of a range of switches:

  • About 1.6 ms of bounce on average
  • Most finished within about 6.2 ms
  • One bad switch bounced for up to 157 ms on release

He recommends a debounce period in the 20–50 ms range, and found that a 100 ms delay is clearly noticeable while 50 ms still feels instant.

Debouncing doesn’t make a button feel slow. Keep it at 50 ms or less and nobody notices the delay.

A worked example: with 30 ms and a user pressing as fast as possible, each press has a held phase and a released phase, each of which must be stable for at least 30 ms, so 60 ms per press → at most about 1,000 ÷ 60 ≈ 16 presses per second, far faster than a finger can go.

0 mspress1-6 mscontacts bounce, reading flickersThenreading settles, timer starts30 ms laterreading unchangedAcceptedcounted as one press
Accept the press only after the reading stays put for the debounce time

A debounce sketch with millis()

The idea: read the pin; if the reading changed, note the time; accept the new value only once it has stayed the same for longer than the debounce time. This sketch toggles an LED once per press.

const int BUTTON_PIN = 4;      // button from GPIO4 to GND
const int LED_PIN = 23;        // LED + resistor from GPIO23 to GND
const unsigned long DEBOUNCE_MS = 30;

int stableState = HIGH;        // accepted state (pull-up: not pressed = HIGH)
int lastReading = HIGH;        // previous raw reading
unsigned long lastChange = 0;  // when the raw reading last changed
bool ledOn = false;

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  int reading = digitalRead(BUTTON_PIN);

  if (reading != lastReading) {       // still bouncing or just changed: restart the timer
    lastChange = millis();
    lastReading = reading;
  }

  if (millis() - lastChange >= DEBOUNCE_MS && reading != stableState) {
    stableState = reading;            // stable long enough: accept it
    if (stableState == LOW) {         // pull-up: pressed = LOW
      ledOn = !ledOn;
      digitalWrite(LED_PIN, ledOn);
    }
  }

  // other work can run here, nothing is blocked by delay()
}

Two deliberate details:

  • millis() - lastChange subtracts unsigned long values, so it stays correct when millis() wraps back to 0 (after about 49.7 days). Don’t write millis() > lastChange + DEBOUNCE_MS.
  • It acts only on the change to LOW (the press edge), not on every loop while the button is held.

For the LED’s resistor on pin 23, see wiring an LED to an ESP32.

Pins to keep buttons off (strapping pins)

Strapping pins are read by the chip at power-up or reset to decide how to boot. A button or circuit that pulls one to the wrong level at that moment can stop the board booting or drop it into download mode. They differ by chip:

ChipStrapping pinsAlso worth knowing
ESP32 (original)GPIO0, 2, 5, 12, 15GPIO34–39 are input-only with no internal pull-up/pull-down; add your own resistor. Pins wired to the internal flash are off limits.
ESP32-C3GPIO2, 8, 9GPIO18/19 are USB by default
ESP32-S3GPIO0, 3, 45, 46GPIO19/20 are USB by default

The table comes from Espressif’s datasheet and hardware design guidelines. For another chip, or a board where the maker wired a pin to something else (an on-board LED, say), check that board’s documents first. Good button pins on the original ESP32 include GPIO4, 13, 14, 18, 19, 21–23, 25–27, 32 and 33 (leave GPIO1 and 3 for the UART used to upload code).

Common mistakes

  • No pull-up or pull-down. It triggers on its own, or when a hand comes near.
  • INPUT_PULLUP on pins 34–39. Those pins have no internal resistors, so the pin still floats.
  • Forgetting the inverted logic. Writing if (digitalRead(pin) == HIGH) and wondering why it fires when not pressed.
  • A button on a strapping pin. The board won’t boot if the button is held at power-up.
  • Too short a debounce, such as 2 ms, less than many switches bounce.
  • Interrupts without debouncing. One press calls the handler several times.

Sources

FAQ

Can I just use delay(50) after reading the button instead of millis()?

In a sketch that does nothing else, yes. But delay() stops the whole program, so LEDs, sensors or Wi-Fi that need updating freeze meanwhile. With millis() the rest of the program keeps running.

With an interrupt, one press counts several times. Why?

The interrupt fires on every change of the pin, bounces included. Debounce inside the interrupt too, for example by remembering the time of the last accepted edge and ignoring edges that come sooner than your debounce time.

With a button on GPIO0 the board won't boot or won't take an upload.

GPIO0 is a strapping pin. If it is pulled LOW at power-up, the chip enters download mode instead of running your program. Move the button to a pin that isn't a strapping pin.

Circuits and code were tested on sample boards. Boards differ in pin layout, so check your board's pinout before you power anything.