ESP32 Timers Complete Guide: Non-blocking millis, Ticker Callbacks and Hardware Timers
Prerequisite: Your First Program already used
millis()for debouncing. This guide covers the complete toolbox for “run this periodically” — from plain delay to microsecond hardware timers, ending with a selection decision tree.
1. Why delay() is a beginner trap
// ❌ Anti-pattern: three tasks dragging each other down
void loop() {
readSensor(); // 10ms
delay(1000); // ← blocked for a full second!
updateDisplay(); // buttons and network messages ignored meanwhile
}
During delay() the CPU spins idle: buttons unread (unless on interrupts), Wi-Fi messages piling up, watchdog creeping toward timeout. Any delay beyond ~50ms is an architecture problem.
2. Layer 1: the millis() non-blocking pattern (enough for 90% of cases)
Core idea: don’t wait — check. Remember each task’s last run time and fire when due:
uint32_t tSensor = 0, tDisplay = 0, tReport = 0;
void loop() {
uint32_t now = millis();
if (now - tSensor >= 100) { tSensor = now; readSensor(); } // 10Hz
if (now - tDisplay >= 1000) { tDisplay = now; updateDisplay(); } // 1Hz
if (now - tReport >= 60000) { tReport = now; reportToServer(); } // once a minute
handleButton(); // always responsive, never blocked
}
Three details:
- Always
now - t >= interval, nevernow >= t + interval— the former survives the ~49.7-day millis() wraparound; the latter deadlocks on it micros()(microsecond version) wraps in ~71.6 minutes — same subtraction rule- Granularity is bounded by loop speed (millisecond class); for microseconds use layer 3
3. Layer 2: Ticker — lightweight periodic callbacks
Prefer tasks not sprawled across loop? Ticker wraps “run when due” as a callback:
#include <Ticker.h>
Ticker timerSensor, timerHeartbeat;
void readSensorTask() { /* read sensors */ }
void heartbeatTask() { /* heartbeat report */ }
void setup() {
timerSensor.attach(0.1, readSensorTask); // every 100ms
timerHeartbeat.attach(30, heartbeatTask); // every 30s
}
Caveats:
- Ticker callbacks run in the timer interrupt context — obey the ISR rules: short, no delay, no printing
attach()takes seconds;attach_ms()takes milliseconds- Heavy-work pattern: the callback sets a flag, loop consumes it
volatile bool flagSensor = false;
void onTimer() { flagSensor = true; } // callback only signals
void loop() {
if (flagSensor) {
flagSensor = false;
readSensor(); // heavy work here
}
}
4. Layer 3: hardware timers — microsecond precision, 3.x API
When you need precise timing (20kHz sampling, exact second pulses, soft real-time), use hardware timers. The classic ESP32 has four 64-bit timer groups; arduino-esp32 3.x API:
hw_timer_t *timer = nullptr;
volatile bool tickFlag = false;
void IRAM_ATTR onTimer() { // still an interrupt: IRAM_ATTR applies
tickFlag = true;
}
void setup() {
timer = timerBegin(1000000); // 1MHz clock (1 tick = 1µs)
timerAttachInterrupt(timer, &onTimer);
timerAlarm(timer, 20000, true, 0); // 20000 ticks = 20ms period, auto-reload
}
void loop() {
if (tickFlag) {
tickFlag = false;
sampleAdc(); // precise 50Hz sampling
}
}
The legacy API (timerBegin(num, divider, countUp)) differs a lot — when an old tutorial errors out, check the version watershed (same story as LEDC).
5. The four-layer selection tree
Your periodic task?
├── Millisecond precision is fine; tasks can live in the main loop
│ └── ① millis() non-blocking pattern (first choice — simplest, controllable)
├── You want "call me when due" structure
│ └── ② Ticker callback (set flag + consume in loop)
├── Microsecond-precise periods (sampling, soft real-time)
│ └── ③ Hardware timer via timerBegin
└── Periodic wake from deep sleep (µA power)
└── ④ RTC timer (timerWake deep-sleep scheme — dedicated article coming)
6. The watchdog: the timer’s opposite
The counterpart of “do something on schedule” is “must feed on schedule”: the ESP32’s task watchdog (TWDT) watches for stuck tasks. A loop blocked too long (~5s unfed) triggers a reset printing:
Task watchdog got triggered. The following tasks did not reset the watchdog in time
Three debugging questions:
- Long
delay()/ tight computation in loop? → go non-blocking - Heavy work inside an ISR/Ticker callback? → move it to loop
- Genuinely long computation? → insert
yield()segments to feed the dog
Production mindset: the watchdog is your last line of defense — never “fix” the error by disabling it.
7. Pitfall cheat sheet (bookmark this)
| Symptom / scenario | Cause and fix |
|---|---|
| Intervals drift over time | Accumulate from now - t baselines (wrap-safe); don’t chain delays |
| Tasks stop after 49.7 days | now > t + interval deadlocks on wrap — use subtraction |
| print in a Ticker callback freezes | Callback is interrupt context — move to loop |
timerBegin argument errors | 2.x/3.x APIs differ; 3.x is timerBegin(1000000) |
| Sampling frequency wanders | millis granularity insufficient — hardware timer |
Task watchdog got triggered | Loop blocked too long — go non-blocking or yield() |
| Periodic deep-sleep wake | RTC timer + deep sleep (upcoming article) |
8. Further reading
- 🧭 Previous: Interrupts & Debouncing — the event-driven other half
- 🧭 Series: ADC × timers = precise periodic sampling
- ⏭️ Next: UART/I2C/SPI — the three buses (coming soon)
License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com