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:

  1. Always now - t >= interval, never now >= t + interval — the former survives the ~49.7-day millis() wraparound; the latter deadlocks on it
  2. micros() (microsecond version) wraps in ~71.6 minutes — same subtraction rule
  3. 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:

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:

  1. Long delay() / tight computation in loop? → go non-blocking
  2. Heavy work inside an ISR/Ticker callback? → move it to loop
  3. 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 / scenarioCause and fix
Intervals drift over timeAccumulate from now - t baselines (wrap-safe); don’t chain delays
Tasks stop after 49.7 daysnow > t + interval deadlocks on wrap — use subtraction
print in a Ticker callback freezesCallback is interrupt context — move to loop
timerBegin argument errors2.x/3.x APIs differ; 3.x is timerBegin(1000000)
Sampling frequency wandersmillis granularity insufficient — hardware timer
Task watchdog got triggeredLoop blocked too long — go non-blocking or yield()
Periodic deep-sleep wakeRTC timer + deep sleep (upcoming article)

8. Further reading


License: CC BY-SA 4.0. Repost & remix with attribution: ESPDIY Maker Lab - espdiy.com