esp32 intermediate 40 min

ESP32: timer interrupts and why loop() timing drifts

Use the ESP32's hardware timers for interrupt-driven timing, understand why millis() and delay() drift under load, and stop your blinking LED from wandering.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

You wire a blink sketch with delay(1000), then add a sensor read, then an OLED update, then an MQTT publish, and suddenly the “1 second” LED blinks every 1.4 seconds and you cannot say where the time went. That drift is not a bug in your code, it is a property of cooperative looping: loop() only runs your timing check when it is not busy doing something else. The fix is to let the hardware keep time. The ESP32 has four hardware timer peripherals that fire interrupts whether your code is busy or not, and this tutorial wires one up, explains the two correct ways to use it, and shows exactly where loop-based timing loses seconds.

The trap I hit: I put Serial.println() inside a timer interrupt (ISR in short, interrupt service routine) and the ESP32 crashed with a Guru Meditation error about a watchdog, repeatedly, with no obvious cause. Printing from an ISR is one of several things that are illegal there, and the ESP32 core’s crash messages do not exactly walk you back to the cause. The rules are short (keep the ISR tiny, no Serial, no delay, only touch data marked volatile, prefer setting a flag over doing work), and once they are habits the timers are the most useful peripheral on the chip.

What you need

Needed

  • ESP32 dev board (e.g. an ESP32-WROOM-32 devkit): the chip with the timers (the classic WROOM has 4 general-purpose hardware timers)
  • Onboard LED (none needed, GPIO 2 blinks on most devkits) or one 5mm LED + 220 ohm resistor: the visual output you time
  • 2x jumper wires plus breadboard: only if you use an external LED

Nice to have

  • A second ESP32 acting as a receiver (or the logic analyzer tutorial’s gear): measures the real intervals your first board produces
  • Multimeter: checks the LED resistor math before power-up
  • Wire stripper: for the LED legs if you go external
  • The oscilloscope tutorial’s cheap scope: the honest way to see drift and jitter as a picture instead of a suspicion

Wiring

External LED version (skip if you use the onboard LED on GPIO 2):

Wire key: GPIOGND
ComponentConnect to
LED anode (long leg)GPIO 2 through a 220 ohm resistor
LED cathode (short leg)GND
Second board’s GPIO 2 (drift probe)First board’s GPIO 2 (GND to GND), optional

GPIO 2 is safe on every common devkit and already wired to an onboard LED, which is why this tutorial uses it. Avoid GPIO 6-11 (flash chip) and the input-only pins (34, 35, 36, 39) for outputs.

Install

Nothing to install. The timer API (hw_timer_t) ships with the ESP32 Arduino core. (The Arduino-ESP32 core changed this API at version 3.x: timerBegin takes fewer arguments and timerAttachInterrupt dropped the edge parameter. The code below is written for core 3.x, with the 2.x differences noted so you can adapt in one pass.)

The code

The mental model: a hardware timer counts clock ticks on its own, independent of your code. When the count hits the value you set, the peripheral fires an interrupt and the CPU jumps to your ISR function no matter what loop() was doing, then jumps back. Your ISR should do almost nothing (set a flag or toggle a pin) and let loop() handle anything with real work in it. That split is the whole design pattern.

#include <Arduino.h>

#define LED_PIN 2

hw_timer_t* timer = NULL;
volatile bool tickFlag = false;      // volatile: ISR writes, loop reads
volatile uint32_t tickCount = 0;

// The ISR: minimal by law, not by style.
// IRAM_ATTR keeps this code in fast internal RAM so it runs even while
// flash is busy (e.g. during an SPI flash write from the other core).
void IRAM_ATTR onTimer() {
  tickFlag = true;                   // set a flag, nothing else
  tickCount++;
  // digitalWrite(LED_PIN, !digitalRead(LED_PIN));  // legal, pin-only
  // Serial.println("tick");   // ILLEGAL: crash (uart not ISR-safe)
  // delay(1);                 // ILLEGAL: blocks inside the ISR
}

void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);

  // Core 3.x API:
  timer = timerBegin(1000000);           // 1 MHz tick: 1 tick = 1 us
  timerAttachInterrupt(timer, &onTimer);
  timerAlarm(timer, 1000000, true, 0);   // 1,000,000 us = 1 s, auto-reload

  // Core 2.x API, if you are on the older core:
  //   timer = timerBegin(0, 80, true);            // timer 0, 80 MHz/80 = 1 MHz
  //   timerAttachInterrupt(timer, &onTimer, true);
  //   timerAlarmWrite(timer, 1000000, true);      // 1 s, auto-reload
  //   timerAlarmEnable(timer);
}

void loop() {
  if (tickFlag) {
    tickFlag = false;                // consume the flag
    digitalWrite(LED_PIN, !digitalRead(LED_PIN));

    // Real work (printing, network, sensors) lives HERE, not in the ISR
    static uint32_t lastPrint = 0;
    uint32_t now = millis();
    if (now - lastPrint >= 5000) {
      lastPrint = now;
      Serial.print("ticks: "); Serial.println(tickCount);
    }
  }

  // loop() can now do ANYTHING slow and blocking without hurting the
  // 1-second LED timing. Try adding delay(3000) right here and watch
  // the LED stay on schedule.
}

Three details that carry the design:

  • volatile is not decoration. Without it the compiler may cache the flag in a register and loop() never sees the ISR’s write. Every variable shared between an ISR and normal code gets volatile, and anything wider than the CPU’s word size (e.g. a 64-bit counter) also needs noInterrupts() / interrupts() around reads in loop().
  • IRAM_ATTR is not decoration either. ISRs must live in internal RAM; without it the first flash operation that lands while the ISR is called crashes the chip.
  • The auto-reload parameter (true) is what makes the timer periodic. With false it fires once and stops, which is the right choice for one-shot timeouts (e.g. a sensor that must answer within 50 ms).

Why loop() timing drifts: the honest accounting

A millis()-based blinker (if (millis() - last >= 1000) { work; last = millis(); }) is fine on an idle chip. The drift arrives with load, and each contributor has a name:

  1. Variable work in loop(). An MQTT publish takes 30 ms on a good network and 400 ms on a bad one. Your 1-second loop becomes “1 second plus whatever the work took” (e.g. 1.4 s with Wi-Fi retries).
  2. The last = millis() reset. Checking millis() again after the work compounds every cycle’s slop instead of cancelling it.
  3. Blocking libraries. Some drivers (certain sensor libs, SD card writes) disable interrupts internally for stretches; millis() itself can lose milliseconds there, which surprises people.
  4. delay() anywhere. A delay(3000) in one branch of loop() makes every other timing check in loop() late by up to 3 seconds.

The hardware timer fixes all four at once: the interval is generated by a peripheral counting microseconds, not by your code getting around to checking. Jitter in the LED toggle becomes a few microseconds (the ISR latency), not hundreds of milliseconds.

Where a hardware timer is the wrong tool

  • Network and sensor work. Keep it in loop() or a FreeRTOS task; the ISR only sets the flag.
  • Anything that can wait 1 ms. The FreeRTOS tick and vTaskDelayUntil() give you scheduling with none of the ISR rules (the multitasking tutorials cover that path).
  • Precision below a few microseconds. The timer interrupt latency is real; for pin-perfect pulse generation use the LEDC (PWM) peripheral or the RMT peripheral instead.

What you learned

  • The ESP32 has four hardware timers that fire interrupts on their own schedule, independent of whatever loop() is doing.
  • An ISR must be tiny: no Serial, no delay, volatile on shared data, IRAM_ATTR on the function, flag-in-ISR / work-in-loop as the pattern.
  • millis()-based timing drifts under load because loop() only checks the clock when it is free; a hardware timer does not have that problem.
  • The timer API differs between Arduino-ESP32 core 2.x and 3.x; the argument shapes changed but the pattern did not.

When something breaks

  • Guru Meditation Error / WDT reset after adding a timer. The ISR is doing illegal work (Serial, delay, allocating memory, calling a library). Strip the ISR down to flag-setting and pin writes only, then add work back in loop() where it belongs. A watchdog reset that names esp_timer or task_wdt almost always points at a fat ISR.
  • Flag never becomes true, LED never blinks. The alarm never fired: check timerAlarm() (3.x) or timerAlarmEnable() (2.x) is actually called, and confirm the divider math (an 80 MHz clock with divider 80 is 1 MHz, so an alarm value of 1,000,000 is 1 second; people set 1000000 expecting 1 ms and get 1 s).
  • loop() misses ticks or the counter skips. tickFlag is not volatile, or tickCount overflows a type too small for it (use uint32_t at minimum). If multiple ISR-shared variables matter together, wrap the loop()-side read in noInterrupts() / interrupts().
  • Timing is exact for minutes, then walks. You are measuring with millis() against a timer: fine, but if the timer itself seems to drift it is usually crystal vs internal RC clock differences across two boards (e.g. one board 20 ppm fast). Two ESP32s never agree exactly; sync over NTP or MQTT instead of expecting it.
  • Crashes only under Wi-Fi load. The ISR and the Wi-Fi driver are fighting for flash time: confirm IRAM_ATTR on the ISR, and if you call any function from it, make sure that function (and what it calls) is also in IRAM. The safe ISR touches nothing but volatile scalars and GPIO.
  • Second board sees jitter of a few hundred microseconds. That is the interrupt latency plus FreeRTOS scheduling on top; it is normal. If you need nanosecond-grade edges, that is a job for the RMT or LEDC peripherals, not a CPU-serviced interrupt.

What to build next

  • The PWM with LEDC tutorial is the hardware-native way to generate exact waveforms (e.g. a 25 kHz fan PWM) with zero CPU involvement.
  • The deep sleep tutorial is the timing story for battery devices: when the timer is the only thing awake, the hardware timer (or the RTC timer) wakes the chip on schedule.
  • The mDNS tutorial and the MQTT publish/subscribe tutorial both benefit from timer-driven publish intervals: steady, drift-free reporting no matter what else the sketch handles.
  • The web socket server tutorial pushes live dashboards, and a timer interrupt is what keeps the push interval honest when clients get slow (the watchdog timer tutorial on the Arduino side covers the “my loop hung” cousin of this problem).