The gap between a tutorial project that barely works and a project that works reliably is surprisingly small. Most of it comes down to a handful of patterns: how you handle timing, how you structure state, how you smooth noisy inputs, how you recover from failures. These are the techniques experienced firmware engineers reach for without thinking, and they transform projects that crash every few hours into ones that run for months.
This article is a tour of those patterns. Every one of them has saved us hours of debugging; most of them also make the code dramatically easier to read.
The delay() trap
Every Arduino tutorial starts with delay(1000). It blinks an LED, it is instantly readable, it feels natural. It is also the single biggest reason hobby firmware becomes unmanageable as projects grow.
delay() blocks. Nothing else can run while the chip is waiting. If your main loop does delay(500), buttons stop responding for half a second, sensor readings get delayed, WiFi reconnection stalls, and any feature you add has to fit around those artificial pauses. For projects with a single blinking LED this is fine. For anything with two concurrent activities it falls apart immediately.
The alternative is non-blocking timing with millis():
unsigned long lastBlink = 0;
const unsigned long BLINK_INTERVAL_MS = 500;
bool ledState = false;
void loop() {
unsigned long now = millis();
if (now - lastBlink >= BLINK_INTERVAL_MS) {
lastBlink = now;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState);
}
// other work can happen here, no blocking
checkButton();
readSensor();
updateDisplay();
}This pattern — compare the current time to a saved timestamp, act if enough time has passed — is the foundation of every multi-activity Arduino project. Once it clicks, you stop writing delay() in main loops.
State machines for flow control
Most interactive firmware is some kind of state machine, whether or not the code acknowledges it. A water dispenser has states: idle, dispensing, flushing, error. A smart lock has states: locked, unlocking, unlocked, locking, jammed. Writing the code as an explicit state machine rather than a pile of nested if statements is the biggest single readability improvement you can make.
A simple state machine for a water dispenser. Each transition has an explicit trigger; the code follows directly.
In code:
enum State { IDLE, DISPENSING, FLUSHING, ERROR };
State state = IDLE;
unsigned long stateEntered = 0;
void loop() {
switch (state) {
case IDLE:
if (buttonPressed()) {
transitionTo(DISPENSING);
}
break;
case DISPENSING:
openValve();
if (!buttonPressed() || elapsedInState() > 10000) {
transitionTo(FLUSHING);
}
break;
case FLUSHING:
runFlush();
if (elapsedInState() > 2000) {
transitionTo(IDLE);
}
break;
case ERROR:
flashErrorLED();
if (resetPressed()) {
transitionTo(IDLE);
}
break;
}
}
void transitionTo(State next) {
state = next;
stateEntered = millis();
}
unsigned long elapsedInState() {
return millis() - stateEntered;
}The state is one variable, transitions are explicit, and adding a new state is a small, local change. This scales to 20 or 30 states before you start wanting a formal tool.
Debouncing: the right way
Mechanical switches bounce. A momentary button press produces 10–30 rapid state changes over 5–20 ms. Read the pin once and you get whatever state the bounce happened to leave it in. The fix is to wait until the reading has been stable for some threshold.
const int BTN_PIN = 2;
const unsigned long DEBOUNCE_MS = 30;
int lastReading = HIGH;
int stableState = HIGH;
unsigned long lastChange = 0;
bool buttonPressed() {
int reading = digitalRead(BTN_PIN);
if (reading != lastReading) {
lastChange = millis();
lastReading = reading;
}
if (millis() - lastChange > DEBOUNCE_MS) {
if (reading != stableState) {
stableState = reading;
if (stableState == LOW) return true; // press edge
}
}
return false;
}This returns true exactly once per button press (on the falling edge), regardless of bouncing. For multiple buttons, the Bounce2 library wraps this pattern in a clean API.
Smoothing noisy sensors
Analog readings — from an ADC, a thermistor, an LDR — jump around by a few bits even when the underlying value is perfectly stable. For slowly changing measurements, an exponential moving average gives you smooth output with one line of arithmetic per sample:
float smoothed = 0;
const float ALPHA = 0.1; // lower = smoother, higher = more responsive
void readTemperature() {
int raw = analogRead(A0);
float tempC = rawToCelsius(raw);
smoothed = ALPHA * tempC + (1 - ALPHA) * smoothed;
}ALPHA around 0.1 means the output follows the input over roughly 10 samples. For fast-changing values (joystick position, accelerometer), use 0.3–0.5. For slow ones (room temperature), 0.05–0.1.
For very noisy sources, a median filter (keep the last N readings, return the middle one) rejects outliers better than averaging. More expensive per sample but more robust.
The watchdog timer
Every microcontroller has a watchdog: a hardware timer that resets the chip if it is not regularly fed. Enable it and your firmware becomes resilient to bugs you have not found yet. If the main loop hangs for any reason, the watchdog fires and restarts the chip. Two minutes of downtime beats a device that needs to be physically unplugged.
On ESP32:
#include "esp_task_wdt.h"
void setup() {
esp_task_wdt_init(10, true); // 10-second timeout, panic on expiry
esp_task_wdt_add(NULL); // register current task
}
void loop() {
esp_task_wdt_reset(); // feed the dog every iteration
// ... your work ...
}On AVR Arduinos:
#include <avr/wdt.h>
void setup() {
wdt_enable(WDTO_8S); // 8-second timeout
}
void loop() {
wdt_reset(); // feed the dog
// ... your work ...
}Pick a timeout comfortably longer than your worst-case legitimate loop iteration. Five seconds is a reasonable default. Too short and you get false resets on normal operations; too long and genuine hangs take a while to recover.
Over-the-air (OTA) updates
Once a device is in a wall, a ceiling, or a waterproof enclosure, physical access for reflashing stops being practical. OTA updates let you push new firmware over WiFi. The Arduino ESP32 core ships a drop-in library that handles the hard parts (partition switching, verification, rollback on boot failure).
#include <WiFi.h>
#include <ArduinoOTA.h>
void setupOTA() {
ArduinoOTA.setHostname("sensor-kitchen-01");
ArduinoOTA.setPassword("a-reasonably-long-password");
ArduinoOTA.onStart([]() { Serial.println("OTA start"); });
ArduinoOTA.onEnd([]() { Serial.println("OTA done"); });
ArduinoOTA.onProgress([](unsigned int p, unsigned int total) {
Serial.printf("OTA %u%%\r", (p * 100) / total);
});
ArduinoOTA.onError([](ota_error_t e) {
Serial.printf("OTA error %u\n", e);
});
ArduinoOTA.begin();
}
void setup() {
Serial.begin(115200);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) delay(200);
setupOTA();
}
void loop() {
ArduinoOTA.handle(); // call every loop
// ... rest of your work ...
}With this in place, upload new firmware from the Arduino IDE by selecting the network port instead of a serial port. For fleets, use espota.py from the command line, or host firmware binaries on an HTTPS endpoint and have the device self-update on a schedule (the HTTPUpdate library covers this pattern).
Two cautions that cost people time. First, set a password — without one, anyone on the same WiFi can overwrite your firmware. Second, keep a USB flashing option physically accessible on your prototype; bricking a device with a bad OTA is easy, and recovery via USB is faster than deep-sleep schemes for rollback.
Power management patterns
For battery projects the default firmware structure is wrong. A normal Arduino loop runs the CPU continuously, which burns battery whether or not you are doing useful work. For any battery-powered project, the right pattern is:
- Wake from deep sleep or standby
- Do the minimum necessary work (take a reading, check a condition)
- Report or act if needed
- Go back to sleep
On ESP32 this is the esp_deep_sleep_start() pattern covered in the WiFi logger project. On AVR Arduinos, use the LowPower library to put the chip in power-down mode between measurements; expect to get years out of a coin cell for a once-per-hour sensor.
A useful rule of thumb: if your project needs to run on battery for more than a week, the firmware must be structured around sleep as the default state, not running as the default state.
Serial command parsing
During development, a serial console is often the quickest way to check state, trigger actions, or configure the device. Rather than building a full protocol, a simple single-line command parser covers most needs:
String buffer = "";
void loop() {
while (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
handleCommand(buffer);
buffer = "";
} else if (c != '\r') {
buffer += c;
}
}
// other work...
}
void handleCommand(const String& cmd) {
if (cmd == "status") { printStatus(); }
else if (cmd == "reboot") { ESP.restart(); }
else if (cmd.startsWith("set interval ")) {
int v = cmd.substring(13).toInt();
setInterval(v);
} else {
Serial.println("unknown command");
}
}Ten commands is the point where this starts to feel cramped; at that point, consider a more structured parser or a proper command-line library.
The main loop, visualised
with debouncing] Buttons --> Sensors{Time to read
sensor?} Sensors -->|Yes| ReadS[Read + smooth] Sensors -->|No| SM[Run state machine tick] ReadS --> SM SM --> Comm{Time to send
to cloud?} Comm -->|Yes| Send[Send update] Comm -->|No| Display[Update display] Send --> Display Display --> End([End iteration
return to top])
The shape of a well-structured main loop: feed the watchdog, process inputs, run the state machine, handle periodic tasks, never block.
Frequently Asked Questions
When should I use FreeRTOS tasks instead of a single loop?
When you have three or more genuinely independent activities with different timing requirements. Three sensors sampled at different rates, a display refresh, a WiFi stack: FreeRTOS tasks give you a cleaner structure than cramming everything into one millis()-scheduled main loop. ESP32 makes this particularly easy because FreeRTOS is already running underneath.
How do I handle very fast sensor updates without missing data?
Use interrupts. Configure the GPIO as an interrupt input, register an ISR that captures the event, and let the main loop process the captured data at its own pace. Keep the ISR minimal: record a timestamp, set a flag, return. Any real work happens outside the ISR.
Should I use delay() at all, ever?
Yes, in two places. Inside setup() before the main loop begins, where blocking is fine. And for very short delays (under 10 ms) for specific timing requirements in protocols, where the blocking is intentional. Inside loop(), prefer millis()-based timing almost always.
How do I debug a crash I cannot reproduce?
Three techniques. First, enable the watchdog and let the crash become a reset you can trigger consistently. Second, sprinkle Serial.println() at state transitions and save the output between resets. Third, on ESP32, read the panic handler output ("Guru Meditation Error") which contains a stack trace — use addr2line to decode addresses into file:line.
What is the best library for organising a complex Arduino project?
No single library; instead, a set of patterns. Each subsystem (sensor, UI, comms) in its own .h/.cpp pair. A shared EventBus or message queue for loose coupling. State machines for control flow. millis() scheduling for periodic work. These compose better than any framework we have tried.
Share your thoughts
Worked with this in production and have a story to share, or disagree with a tradeoff? Email us at support@mybytenest.com — we read everything.