Beyond Blink: Build a Responsive Arduino System Without delay()

Written by

in

A blinking LED is often a student’s first working embedded project. Add a pushbutton, however, and an unexpected problem appears: the light behaves correctly, but the button sometimes seems to do nothing. The problem may be the program’s timing rather than the wiring.

In this lesson, you will build a small Arduino system that starts and stops blinking when you press a button. It will keep checking the button while waiting for the next LED change. That small design teaches a useful habit: describe what the system should do now, then return quickly so other work can happen.

Try the interactive simulator

Start here: choose Compile & run, then Tap button to start blinking. Tap again to stop. No board or installation is needed. Choose Start tour inside the lab for a guided introduction, or open the full lab for more room.

Edit the actual Arduino sketch, explore the wiring, and test your changes. The lab emulates Uno firmware and digital connections; analog electrical behavior is not modeled. The explanation below walks through the same circuit and code.

What you will learn

  • Explain why a polling loop can miss a button press during a blocking delay.
  • Schedule LED changes using elapsed time.
  • Represent behavior using two explicit states.
  • Debounce a mechanical button and react once per press.
  • Test ordinary behavior, startup behavior, and timer rollover.

You should already be comfortable with variables, conditions, functions, and basic digital input/output in C/C++. Allow about 30–45 minutes for the practical activity.

Why the button seems unresponsive

Imagine that your loop switches the LED on, calls delay(500), switches it off, and calls delay(500) again. If it reads the button only once per loop, a short press can begin and end between two reads. The program never observes that press.

A delay pauses ordinary execution of your sketch at that point. It does not mean that every hardware peripheral or interrupt stops. The relevant limitation here is simpler: your next polling read cannot run until control reaches it. Arduino’s official Blink Without Delay example demonstrates an alternative based on elapsed-time checks.

Think of checking a wall clock while doing several chores. You do not stand still for five minutes waiting to check the oven. You do another short task, glance at the time, and act when the interval has passed.

Parts and wiring

This example targets an Arduino Uno R3. You need the board, a USB cable, a momentary pushbutton, two jumper wires, and optionally a breadboard. The onboard LED avoids a separate LED circuit.

Arduino Uno R3

 D2  ────────── o   o ────────── GND
               pushbutton
             (normally open)

 LED_BUILTIN → onboard LED
 INPUT_PULLUP → internal pull-up on D2
The pushbutton connects D2 to ground when pressed. No connection to 5 V is needed for this circuit.

Disconnect USB power while changing the wiring. On a four-leg tactile switch, some legs are permanently joined internally. Use a continuity check or the switch’s diagram to choose contacts that connect only when pressed; otherwise D2 may remain tied to ground.

INPUT_PULLUP enables the input’s internal pull-up. The unpressed button therefore reads HIGH, and pressing it connects the input to ground so it reads LOW. This active-low arrangement follows Arduino’s digital-pin documentation. Do not assume the voltage and pin arrangement are identical on every board.

Define the behavior before writing code

The system begins in Idle, with the LED off. A confirmed press enters Blinking and turns the LED on immediately. Another confirmed press returns to Idle and turns it off. Holding the button does not repeatedly change modes.

Current state Event Action Next state
Idle Debounced press Turn LED on; restart blink timer Blinking
Blinking 500 ms elapsed Toggle LED; restart blink timer Blinking
Blinking Debounced press Turn LED off Idle
Either Release or held button Update input tracking only Unchanged

A 500 ms interval is the time between LED changes. One complete on/off cycle takes about one second. Naming the interval clearly avoids confusing toggle rate with blink-cycle frequency.

The complete sketch

Select Arduino Uno in your IDE, paste the sketch below into a new project, and upload it. The three tasks in loop() are deliberately short: inspect the raw input, confirm a stable input change, and update the LED when due.

// Hamnus teaching example: Arduino Uno R3, button from D2 to GND.
const byte BUTTON_PIN = 2;
const unsigned long DEBOUNCE_MS = 30UL;
const unsigned long BLINK_MS = 500UL;

enum class Mode { Idle, Blinking };
Mode mode = Mode::Idle;
bool ledOn = false;
int lastRaw = HIGH;
int stableButton = HIGH;
unsigned long rawChangedAt = 0;
unsigned long ledChangedAt = 0;

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, LOW);
  lastRaw = digitalRead(BUTTON_PIN);
  stableButton = lastRaw; // A held button at startup needs release first.
  rawChangedAt = millis();
}

void loop() {
  const unsigned long now = millis();
  const int raw = digitalRead(BUTTON_PIN);

  if (raw != lastRaw) {
    lastRaw = raw;
    rawChangedAt = now;
  }

  if (now - rawChangedAt >= DEBOUNCE_MS && raw != stableButton) {
    stableButton = raw;
    if (stableButton == LOW) { // Act once on a debounced press.
      if (mode == Mode::Idle) {
        mode = Mode::Blinking;
        ledOn = true;
        ledChangedAt = now;
      } else {
        mode = Mode::Idle;
        ledOn = false;
      }
      digitalWrite(LED_BUILTIN, ledOn ? HIGH : LOW);
    }
  }

  if (mode == Mode::Blinking && now - ledChangedAt >= BLINK_MS) {
    ledChangedAt = now;
    ledOn = !ledOn;
    digitalWrite(LED_BUILTIN, ledOn ? HIGH : LOW);
  }
}

How the timing works

millis() provides an elapsed-time counter. We take one reading at the start of each loop and use it for that pass. The key expression is now - ledChangedAt >= BLINK_MS: has enough time passed since the last LED change?

Until the answer becomes yes, execution continues through the loop. No waiting loop is needed. This is cooperative scheduling: each piece of work returns quickly enough for the others to run. It is not proof that tasks execute simultaneously or that the program meets a hard real-time deadline.

The sketch assigns ledChangedAt = now when it changes the LED. That gives a simple minimum interval between changes, but processing delays can shift the overall rhythm. A precision sampling application needs an explicit timing-error budget and possibly hardware timers; this teaching example does not promise precision timing.

Why debounce the button?

A mechanical contact can alternate between open and closed briefly during a press or release. Reacting to each raw change can produce several mode changes from one physical action. Arduino’s official debounce example shows how timing can qualify a stable input.

Here, every raw change restarts rawChangedAt. We accept the new level only after it remains unchanged for 30 ms. We then act only when the accepted level becomes LOW. The stable HIGH transition on release rearms the next press but does not switch modes.

Thirty milliseconds is a starting design choice, not a universal property of switches. Increase it if your particular switch still produces unwanted changes, then consider the added response delay. A pulse shorter than the debounce interval may be ignored intentionally. A stable change also needs to be observed by the loop, so responsiveness depends on the rest of the program remaining short.

Handle startup and timer rollover

In setup(), both tracked button levels are initialized from the actual pin reading. If the board starts while you hold the button down, the LED stays off. Release the button long enough to be accepted, then press again. This makes startup behavior deliberate rather than accidental.

On the Uno R3, the unsigned 32-bit millisecond counter wraps after roughly 49.7 days. Arduino’s millis reference describes the counter and its return type. Unsigned subtraction supports our short elapsed-time checks across a wrap, provided the counter is checked regularly and a complete wrap is not missed.

For example, if the earlier count is 4,294,967,280 and the current count is 20 after wrapping, the unsigned 32-bit difference is 36 ms. Keep timestamp variables unsigned. Avoid rewriting the condition as now >= previous + interval, because the future timestamp itself can wrap.

Test the behavior, not just whether it compiles

  1. Normal start: reset without pressing the button. The LED should stay off.
  2. Start blinking: press and release. The LED should turn on after the input is accepted, then alternate roughly every 500 ms.
  3. Stop at different moments: press while the LED is on, then repeat while it is off. Both should return to Idle.
  4. Hold: keep the button down for several seconds. It should change the mode once, rather than cycle repeatedly.
  5. Startup hold: reset while holding the button. Confirm that release and a new press are needed to start.
  6. Short pulses: compare a quick tap with a deliberately longer press. Explain why an input shorter than the chosen stability window can be rejected.

Verification note: The original sketch passed a C++ host simulation for contact bounce, held-button behavior, stopping in either LED phase, startup with the button held, rejection of short pulses, and unsigned-counter rollover. The host check used simulated input/output and the host counter width; it was not an Arduino hardware or target-toolchain test. No physical Arduino board was used for this article’s checks. The list above is the hardware validation you should perform; observed electrical behavior and real response times are not claimed here.

Troubleshooting

  • Nothing happens: confirm the selected board, successful upload, D2-to-button connection, and ground connection.
  • The button seems permanently pressed: check whether the switch legs used are internally joined. With this wiring, pressed means LOW.
  • One press changes the mode several times: inspect the contacts and stability window; also make sure you act on the accepted transition rather than every LOW reading.
  • It works until you add another task: inspect long delays, waiting loops, slow communication, and excessive logging. Removing one delay() does not make every later operation nonblocking.

Extend the design to three states: Idle, Slow Blink, and Fast Blink. Each confirmed press should advance to the next state, eventually returning to Idle. Use 500 ms between changes for Slow Blink and 125 ms for Fast Blink.

Before editing the sketch, draw a transition table and decide what happens to the LED and timer when the state changes. Submit your table, code, wiring photo, and a short test record. Explain how you prevented a held button from skipping states, and identify one operation that could still delay input handling.

The transferable skill is to separate inputs, timing, state, and outputs. Once those responsibilities are clear, adding a sensor or another indicator becomes a design exercise you can reason about and test.

Report an issue with this lesson or article