Zbotic Logo Zbotic Logo
  • Home
  • Shop
  • Sale
  • 3D Print Service
  • PCB Service
  • B2B
  • Blogs
  • Contact Us
0 0

View Wishlist Add all to cart

0 0
0 Shopping Cart
Shopping cart (0)
Subtotal: ₹0.00

View cartCheckout

  • Shop
  • About Us
  • Contact Us
  • Reseller
  • Blogs
020 69134444
1800 209 0998
[email protected]
Help Desk
Facebook Twitter Instagram Linkedin YouTube
Zbotic Logo Zbotic Logo
0 0

View Wishlist Add all to cart

0 0
0 Shopping Cart
Shopping cart (0)
Subtotal: ₹0.00

View cartCheckout

All departments
  • 3D Print Service
  • 3D Printer
  • Batteries & Chargers
  • Development Boards
  • Drone Parts
  • EBike parts
  • Sensor Modules
  • Electronic Components
  • Electronic Modules
  • IoT and Wireless
  • Mechanical Parts and Workbench Tools
  • Motors & Drivers & Pumps & Actuators
  • DIY and Robot Kits
  • Show more
  • Home
  • Shop
  • Sale
  • 3D Print Service
  • PCB Service
  • B2B
  • Blogs
  • Contact Us
Return to previous page
Home Arduino & Microcontrollers

Arduino Event-Driven Programming: Non-Blocking Code Patterns

Arduino Event-Driven Programming: Non-Blocking Code Patterns

March 11, 2026 /Posted byJayesh Jain / 0

If you’ve ever written an Arduino sketch that used delay() and found that buttons stopped responding, sensors missed readings, or your project froze for seconds at a time — you’ve already experienced the core problem that Arduino non-blocking event-driven programming solves. The delay() function halts the entire microcontroller: no inputs are read, no outputs updated, nothing happens. For simple blink programs this is fine. For real projects that must respond to multiple events simultaneously, you need a fundamentally different approach. This guide teaches you exactly that — from the foundational millis() timer pattern, through finite state machines, to hardware interrupts.

Table of Contents

  • Why delay() is a Problem
  • The millis() Pattern: Core of Non-Blocking Code
  • Managing Multiple Independent Timers
  • Finite State Machines for Event-Driven Logic
  • Hardware Interrupts for Instant Response
  • Non-Blocking Button Debounce
  • Putting It All Together: A Real Example
  • Frequently Asked Questions
  • Conclusion

Why delay() is a Problem

The delay(ms) function calls a spin-loop inside the AVR (or ARM) hardware. The processor does nothing but count clock cycles until the specified time passes. During this time:

  • No digitalRead() or analogRead() calls execute — sensor values are missed
  • Serial receive buffer can overflow if data arrives faster than your read rate
  • No digitalWrite() executes — other outputs freeze
  • Software interrupt callbacks (like attachInterrupt handlers) still fire, but your main loop cannot process their results

Consider a real scenario: you want to blink an LED every 500ms AND read a button press to toggle a second LED. With delay(500), the button read only happens twice per second. Any button press shorter than 500ms may be missed entirely. Now imagine adding a sensor read every 100ms, a serial command parser, and a servo update every 20ms — it becomes impossible with delay().

The solution: never block. Instead, record the time you want something to happen, then check each loop() iteration whether that time has arrived. This is the event-driven model.

Recommended: Arduino Nano Every with Headers — The ATmega4809 in the Nano Every has 48 MHz clock and 6 KB SRAM — more breathing room for complex event-driven sketches with many state variables and timers.

The millis() Pattern: Core of Non-Blocking Code

millis() returns the number of milliseconds since the Arduino was last powered on or reset, stored as an unsigned 32-bit integer. It overflows after approximately 49.7 days, but using the subtraction trick below makes your code overflow-safe:

// Non-blocking LED blink using millis()
const int LED_PIN = 13;
const unsigned long BLINK_INTERVAL = 500; // milliseconds

unsigned long lastBlinkTime = 0;
bool ledState = false;

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

void loop() {
  unsigned long now = millis();
  
  // Check if it's time to toggle the LED
  if (now - lastBlinkTime >= BLINK_INTERVAL) {
    lastBlinkTime = now; // Save the time we last toggled
    ledState = !ledState;
    digitalWrite(LED_PIN, ledState ? HIGH : LOW);
  }
  
  // Everything below runs without any delay!
  // Other tasks can go here...
}

The key insight: now - lastBlinkTime gives the elapsed time since the last blink. Because both variables are unsigned long, this subtraction works correctly even when millis() overflows (unsigned arithmetic wraps around predictably). When the elapsed time exceeds your interval, take the action and reset the timestamp.

Notice lastBlinkTime = now rather than lastBlinkTime += BLINK_INTERVAL. Both work, but += INTERVAL is preferred when you need precise timing (it accumulates exactly, rather than adding jitter from loop execution time). For most projects the difference is negligible.

Managing Multiple Independent Timers

The real power emerges when you run multiple independent timers in the same loop. Each task gets its own lastTime variable and interval:

// Multiple non-blocking tasks
unsigned long lastLedTime = 0;
unsigned long lastSensorTime = 0;
unsigned long lastSerialTime = 0;

bool ledState = false;
float temperature = 0.0;

void loop() {
  unsigned long now = millis();
  
  // Task 1: Blink LED every 500ms
  if (now - lastLedTime >= 500) {
    lastLedTime = now;
    ledState = !ledState;
    digitalWrite(13, ledState);
  }
  
  // Task 2: Read temperature every 2000ms
  if (now - lastSensorTime >= 2000) {
    lastSensorTime = now;
    temperature = readTemperatureSensor(); // your sensor function
  }
  
  // Task 3: Print status every 1000ms
  if (now - lastSerialTime >= 1000) {
    lastSerialTime = now;
    Serial.print("Temp: ");
    Serial.println(temperature);
  }
  
  // Task 4: Check button every loop iteration (instant response)
  checkButton();
}

All three tasks run independently at their own rates, and button checking runs on every loop iteration for instant response. This is fundamentally more scalable than any delay-based approach. As your project grows, add more lastXTime variables and if blocks.

For cleaner code in larger projects, consider using a helper struct or class to group interval, last-time, and callback:

struct Task {
  unsigned long interval;
  unsigned long lastRun;
  void (*callback)();
};

void blinkLed() { /* blink code */ }
void readSensor() { /* sensor code */ }

Task tasks[] = {
  {500,  0, blinkLed},
  {2000, 0, readSensor}
};

void loop() {
  unsigned long now = millis();
  for (int i = 0; i < 2; i++) {
    if (now - tasks[i].lastRun >= tasks[i].interval) {
      tasks[i].lastRun = now;
      tasks[i].callback();
    }
  }
}

Finite State Machines for Event-Driven Logic

A Finite State Machine (FSM) is a programming pattern where your system is always in one of a defined set of states, and events cause transitions between states. This is the most powerful pattern for complex event-driven Arduino code.

Example: a door lock system with states LOCKED, UNLOCKING, OPEN, LOCKING:

enum class LockState {
  LOCKED,
  UNLOCKING,  // servo moving to open position
  OPEN,
  LOCKING     // servo moving to closed position
};

LockState state = LockState::LOCKED;
unsigned long stateEnteredAt = 0;
const unsigned long UNLOCK_TIME = 500;  // 500ms to fully unlock
const unsigned long OPEN_TIME = 5000;   // Stay open 5 seconds

void updateLock() {
  unsigned long now = millis();
  
  switch (state) {
    case LockState::LOCKED:
      // Wait for keypad input or RFID event
      if (accessGranted()) {
        state = LockState::UNLOCKING;
        stateEnteredAt = now;
        startUnlockServo(); // begins servo movement
      }
      break;
      
    case LockState::UNLOCKING:
      if (now - stateEnteredAt >= UNLOCK_TIME) {
        state = LockState::OPEN;
        stateEnteredAt = now;
      }
      break;
      
    case LockState::OPEN:
      if (now - stateEnteredAt >= OPEN_TIME) {
        state = LockState::LOCKING;
        stateEnteredAt = now;
        startLockServo();
      }
      break;
      
    case LockState::LOCKING:
      if (now - stateEnteredAt >= UNLOCK_TIME) {
        state = LockState::LOCKED;
      }
      break;
  }
}

void loop() {
  updateLock();
  // other tasks here — no blocking anywhere
}

The FSM approach makes the code easy to reason about and extend. Adding a new state (like ALARM) simply means adding an enum value and a case in the switch. Testing is easier too — you can add Serial.print statements that show state transitions, making debugging straightforward.

Recommended: Arduino Mega 2560 R3 Board — For complex FSM projects with many states, sensors, and outputs, the Mega’s 256 KB flash and 8 KB SRAM give you the space to build without worrying about memory limits.

Hardware Interrupts for Instant Response

Even with millis()-based polling, you can miss short events if your loop takes too long. Hardware interrupts solve this: when a specified pin changes state, the processor immediately pauses whatever it’s doing and runs your Interrupt Service Routine (ISR).

volatile bool buttonPressed = false;
unsigned long lastPressTime = 0;

void buttonISR() {
  // Keep ISRs short! Just set a flag.
  buttonPressed = true;
  lastPressTime = millis();
}

void setup() {
  pinMode(2, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(2), buttonISR, FALLING);
  Serial.begin(9600);
}

void loop() {
  if (buttonPressed) {
    buttonPressed = false; // clear the flag first
    Serial.println("Button was pressed!");
    // Handle the event here
  }
  // Rest of loop runs normally
}

Critical rules for ISRs:

  1. Keep them extremely short. No Serial.print, no delay, no heavy computation. Set a flag and return.
  2. Declare shared variables as volatile. This prevents the compiler from caching the value in a register and missing updates from the ISR.
  3. On Uno, only pins 2 and 3 support external interrupts. The Mega adds pins 18–21. Nano Every and 32-bit boards have more options.
  4. Protect multi-byte reads with noInterrupts()/interrupts() in the main loop when reading variables updated by ISRs.

Non-Blocking Button Debounce

Mechanical buttons bounce — they make and break contact many times in the first 5–50 milliseconds after being pressed. Without debounce, one press registers as many presses. The non-blocking debounce pattern tracks button state history:

const int BUTTON_PIN = 2;
const unsigned long DEBOUNCE_DELAY = 50; // 50ms debounce window

bool lastButtonState = HIGH;
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;

bool buttonJustPressed() {
  bool reading = digitalRead(BUTTON_PIN);
  
  if (reading != lastButtonState) {
    lastDebounceTime = millis(); // restart debounce timer
  }
  
  lastButtonState = reading;
  
  if (millis() - lastDebounceTime >= DEBOUNCE_DELAY) {
    if (reading != buttonState) {
      buttonState = reading;
      if (buttonState == LOW) return true; // button just pressed
    }
  }
  return false;
}

void loop() {
  if (buttonJustPressed()) {
    Serial.println("Clean button press detected");
  }
  // All other tasks continue here
}

Putting It All Together: A Real Example

Here is a complete non-blocking sketch that blinks an LED, reads a temperature sensor every 3 seconds, prints status to serial every second, and responds instantly to a button press — all simultaneously, no delays anywhere:

#include <DHT.h>

#define DHTPIN 4
#define DHTTYPE DHT11
DHT dht(DHTPIN, DHTTYPE);

const int LED_PIN = 13;
const int BUTTON_PIN = 2;

unsigned long lastLed = 0, lastSensor = 0, lastSerial = 0;
bool ledState = false;
float temp = 0, hum = 0;
bool lastBtn = HIGH, btnState = HIGH;
unsigned long lastDebounce = 0;
int pressCount = 0;

void setup() {
  Serial.begin(9600);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  dht.begin();
}

void loop() {
  unsigned long now = millis();
  
  // Task 1: Blink LED every 1000ms
  if (now - lastLed >= 1000) { lastLed = now; ledState = !ledState; digitalWrite(LED_PIN, ledState); }
  
  // Task 2: Read DHT sensor every 3000ms
  if (now - lastSensor >= 3000) { lastSensor = now; temp = dht.readTemperature(); hum = dht.readHumidity(); }
  
  // Task 3: Print status every 1000ms
  if (now - lastSerial >= 1000) { lastSerial = now; Serial.print("T:"); Serial.print(temp); Serial.print("C H:"); Serial.print(hum); Serial.print("% Presses:"); Serial.println(pressCount); }
  
  // Task 4: Debounced button check (runs every loop)
  bool reading = digitalRead(BUTTON_PIN);
  if (reading != lastBtn) lastDebounce = now;
  lastBtn = reading;
  if (now - lastDebounce >= 50 && reading != btnState) {
    btnState = reading;
    if (btnState == LOW) pressCount++;
  }
}
Recommended: DHT11 Temperature and Humidity Sensor Module — A perfect companion for your non-blocking temperature monitoring sketch. The DHT11 requires a 2-second minimum between reads — exactly the kind of timed task that millis() handles beautifully.
Recommended: Arduino Nano 33 IoT with Header — The SAMD21 on the Nano 33 IoT supports true DMA and more sophisticated interrupt routing, making event-driven patterns even more powerful for connected IoT applications.

Frequently Asked Questions

When does millis() overflow and does it cause bugs?

millis() overflows after 4,294,967,295 ms — approximately 49.7 days. Using the subtraction pattern now - lastTime with unsigned long variables handles overflow correctly because unsigned subtraction wraps around in a mathematically predictable way. As long as your interval is shorter than 49.7 days (it always is), the timer works correctly through overflow.

Can I replace delay() with millis() in all situations?

For most cases, yes. The only legitimate use of delay() is in setup() for hardware initialisation sequences where blocking is acceptable (like waiting for a sensor to boot). In loop(), delay() should almost never appear in production code. Use millis() for all timing in loop().

What is the minimum resolution of millis() on Arduino?

On the Arduino Uno and Nano (16 MHz AVR), millis() has 1ms resolution for most calls, but occasionally 2ms due to the timer overflow interrupt implementation. For sub-millisecond timing, use micros() which has 4 microsecond resolution on 16 MHz boards. On 48 MHz boards like the Nano Every, micros() has higher resolution.

Are there libraries that make this easier?

Yes. Popular non-blocking timer libraries include: SimpleTimer, TaskScheduler (feature-rich, supports priorities and sleep), and ArduinoThread. These wrap the millis() pattern in clean APIs. Learning the raw pattern first is recommended so you understand what the libraries do under the hood.

What is the difference between polling and interrupts for button reading?

Polling checks the button state every loop iteration — it works when your loop() runs frequently (1000+ times per second). Interrupts trigger immediately when the pin changes, even if your loop is busy. Use interrupts for timing-critical events (encoder counting, pulse width measurement) and polling with debounce for regular buttons in most projects.

Conclusion

Moving from delay()-based code to millis()-based non-blocking patterns is arguably the single most important skill step for an Arduino programmer beyond the basics. Your sketches become more responsive, more scalable, and more professional. The patterns scale from a simple LED blinker all the way to complex multi-sensor data loggers, robotics controllers, and IoT devices. State machines add the structured logic layer needed for anything beyond simple on/off control. And hardware interrupts provide the instant response guarantee that polling cannot offer for time-critical signals. Master these three patterns — millis() timers, FSMs, and interrupts — and you’ll have the tools to build any embedded project you can imagine.

Level up your projects with better hardware. Browse Arduino boards and sensors at Zbotic.in — shipped across India.

Tags: Arduino, embedded programming, event driven, interrupts, millis, non-blocking, state machine
Share Post
  • Facebook
  • Linkedin
  • Whatsapp
Arduino Nano BLE 33: Bluetooth...
blog arduino nano ble 33 bluetooth 5 projects for wearables 594785
blog arduino liquid crystal i2c reduce wiring to just 2 pins 594793
Arduino Liquid Crystal I2C: Re...

Related posts

Svg%3E
Read more

Arduino Batch Programming: Flash Multiple Boards Quickly

April 1, 2026 0
Table of Contents Introduction Components and Hardware Setup Wiring Diagram and Connections Complete Code with Explanation Customization and Improvements Troubleshooting... Continue reading
Svg%3E
Read more

Arduino Based Radar System with Ultrasonic Sensor

April 1, 2026 0
Table of Contents Introduction Components and Hardware Setup Wiring Diagram and Connections Complete Code with Explanation Customization and Improvements Troubleshooting... Continue reading
Svg%3E
Read more

Arduino Automatic Plant Monitor: Sunlight, Moisture, Temperature

April 1, 2026 0
Table of Contents Introduction Components and Hardware Setup Wiring Diagram and Connections Complete Code with Explanation Customization and Improvements Troubleshooting... Continue reading
Svg%3E
Read more

Arduino Lie Detector: GSR Sensor Polygraph Project

April 1, 2026 0
Table of Contents Introduction Components and Hardware Setup Wiring Diagram and Connections Complete Code with Explanation Customization and Improvements Troubleshooting... Continue reading
Svg%3E
Read more

Arduino Metal Detector: Build a Treasure Finder

April 1, 2026 0
Table of Contents Introduction Components and Hardware Setup Wiring Diagram and Connections Complete Code with Explanation Customization and Improvements Troubleshooting... Continue reading

Add comment Cancel reply

Your email address will not be published. Required fields are marked

Facebook Twitter Instagram Pinterest Linkedin Youtube

Get the latest deals and more.

Download on Google Play Download on the App Store

Call us: 020 69134444 / 1800 209 0998

Monday - Saturday 09:30 AM - 06:00 PM
For Technical Supports Email: [email protected]
For Sales / Enquiries Email: [email protected]

  • My Account

    • Cart

    • Wishlist

    • Checkout

    • My Orders

    • Track Order

    • My Account

  • Information

    • FAQs

    • Blogs

    • Career

    • About Us

    • Contact Us

    • Payment Options

  • Policies

    • Privacy Policy

    • Terms & Conditions

    • GST Input Tax Credit

    • Shipping Return Policy

    • E-Waste Collection Points

    • Our Sitemap

© Zbotic.in is registered trademark of Moxie Supply Pvt Ltd – All Rights Reserved
Login
Use Phone Number
Use Email Address
Not a member yet? Register Now
Reset Password
Use Phone Number
Use Email Address
Register
Already a member? Login Now