Introduction
Timers and interrupts are two of the most useful features of the ESP32 for building responsive embedded systems. They allow your code to react to events quickly and perform tasks at specific intervals without relying only on slow or wasteful polling loops. If you want an ESP32 to respond to a button press immediately, measure signal timing, trigger repeated actions at fixed intervals, or handle time-sensitive hardware behavior, timers and interrupts are often the right tools.
Many beginners first learn ESP32 programming with simple loop() code and delay() calls. That approach is fine for small experiments, but it becomes limiting very quickly. A program that spends too much time waiting can miss external events, respond slowly, or become difficult to scale when more features are added. Timers and interrupts solve that by letting the microcontroller react when something happens, rather than only when the main loop happens to check for it.
This article explains how timers and interrupts work on ESP32, why they matter, how they differ from normal program flow, and how to use them safely in practical projects.
What interrupts are
An interrupt is a mechanism that temporarily pauses normal program flow so the ESP32 can respond to an important event. Instead of checking continuously whether something happened, the microcontroller can be told to stop what it is doing for a moment, run a special function, and then return to the main code.
That special function is called an interrupt service routine, often shortened to ISR.
This matters because some events should not wait. A button press, a pulse from a sensor, a change in a signal line, or a timer event may need fast attention. If your code only checks for those events inside a slow main loop, the response may be delayed or inconsistent. Interrupts provide a much more immediate response.
The main idea is simple. Your normal code keeps running. When a configured event occurs, the ESP32 automatically jumps to the ISR, executes it, and then returns to the main program.
What timers are
A timer is a hardware feature that counts time and can trigger actions at defined intervals. On ESP32, timers are useful when you want something to happen regularly or after a specific time period without depending on repeated delay() calls in the main loop.
For example, you might want to toggle an LED every second, sample a sensor every 10 milliseconds, or increment a counter at a fixed rate. A hardware timer can trigger that action more reliably than manually checking elapsed time in some situations.
Timers and interrupts often work together. A timer can generate an interrupt when it reaches a certain value. That means the timer provides the timing, and the interrupt provides the immediate response.
Why timers and interrupts matter on ESP32
The ESP32 is capable of handling many tasks at once, including wireless communication, GPIO control, sensors, and background system work. In that kind of environment, simple blocking code can cause problems. If the program sits in a delay or spends too long doing one job, it may miss something important happening elsewhere.
Interrupts help solve this by allowing urgent events to be handled quickly. Timers help solve it by providing precise repeated timing without forcing the program into awkward waiting loops.
This becomes especially useful in projects such as:
A pulse counter that must catch fast incoming signals.
A button-controlled system that should react instantly.
A periodic sensor sampler that needs regular timing.
A motor or actuator controller that depends on timed behavior.
A system that must perform repeated tasks while still leaving the main loop free for other work.
Once you understand timers and interrupts, ESP32 programs become much more flexible and responsive.
Interrupts versus polling
A useful way to understand interrupts is to compare them with polling.
Polling means the program repeatedly checks whether something has happened. For example, in the loop() function you might keep reading a button pin to see whether it changed.
Example of polling:
const int buttonPin = 12;
void setup() {
pinMode(buttonPin, INPUT_PULLUP);
Serial.begin(115200);
}
void loop() {
if (digitalRead(buttonPin) == LOW) {
Serial.println("Button pressed");
}
}
This works, but it has limits. If the loop becomes busy with other work, the button may be checked less often. If timing matters, the response may be inconsistent.
An interrupt-based approach lets the hardware notify the program immediately when the input changes, rather than waiting for the loop to notice it.
That is the main advantage: interrupts are event-driven.
A basic external interrupt example
One common use of interrupts on ESP32 is responding to a button press or signal change on a GPIO pin.
Example:
const int buttonPin = 12;
volatile bool buttonPressed = false;
void IRAM_ATTR handleButtonInterrupt() {
buttonPressed = true;
}
void setup() {
Serial.begin(115200);
pinMode(buttonPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(buttonPin), handleButtonInterrupt, FALLING);
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
Serial.println("Button interrupt detected");
}
}
This example introduces several important ideas.
The function handleButtonInterrupt() is the ISR. It runs automatically when the configured edge occurs on the button pin.
The line attachInterrupt(...) connects the interrupt event to the ISR.
The FALLING mode means the interrupt triggers when the signal changes from high to low.
The variable buttonPressed is marked volatile because it is shared between normal code and interrupt code.
This is a good pattern for beginners. The ISR does something very small and fast by setting a flag. Then the main loop notices the flag and performs the more complex work.
Why volatile matters
When a variable is shared between an ISR and normal program code, it should usually be declared as volatile.
Example:
volatile bool buttonPressed = false;
This tells the compiler that the value can change unexpectedly, outside the normal visible flow of the program. Without volatile, the compiler might optimize access to the variable in a way that causes incorrect behavior.
A common beginner mistake is using a shared variable without volatile and then wondering why the main loop does not seem to notice updates from the interrupt.
In interrupt-based code, volatile is often essential for correctness.
Interrupt trigger modes
When attaching an interrupt, you usually specify the kind of signal change that should trigger it.
Common modes include:
RISING for a low-to-high change.
FALLING for a high-to-low change.
CHANGE for either direction.
HIGH or LOW in some cases, depending on the environment and use.
For a push button wired with a pull-up resistor, FALLING is often used because the pin normally sits high and goes low when pressed.
Choosing the correct trigger mode matters because it determines exactly when the ISR runs.
Why ISRs must be short and simple
One of the most important rules of interrupts is this: keep the ISR short.
An ISR should do as little as possible. Ideally, it should set a flag, increment a counter, capture a timestamp, or perform some very small operation. It should not contain long delays, complicated logic, large memory operations, or code that might block.
A good ISR:
void IRAM_ATTR handleInterrupt() {
eventCount++;
}
A bad ISR would try to print lots of serial messages, wait using delay(), allocate memory, or perform large calculations.
The reason is simple. Interrupts temporarily take control away from normal execution. If the ISR runs too long, it can interfere with other important system tasks and make the whole program less reliable.
A strong rule of thumb is this: let the ISR record that something happened, then let the main loop handle the bigger response.
About IRAM_ATTR
In many ESP32 interrupt examples, you will see the ISR declared like this:
void IRAM_ATTR handleInterrupt() {
}
This attribute places the function in instruction RAM, which helps ensure it is available when needed during interrupt handling. On ESP32, this is a standard practice for ISR functions.
Even if a simple interrupt appears to work without it in some situations, using IRAM_ATTR is the safer and more common approach for interrupt handlers.
A better button interrupt example with debouncing
Mechanical buttons often bounce, which means one physical press can generate multiple rapid signal changes. If you use an interrupt directly on a button without any protection, one press may trigger several times.
A basic software approach is to ignore interrupts that occur too soon after the last one.
Example:
const int buttonPin = 12;
volatile bool buttonPressed = false;
volatile unsigned long lastInterruptTime = 0;
void IRAM_ATTR handleButtonInterrupt() {
unsigned long currentTime = millis();
if (currentTime - lastInterruptTime > 200) {
buttonPressed = true;
lastInterruptTime = currentTime;
}
}
void setup() {
Serial.begin(115200);
pinMode(buttonPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(buttonPin), handleButtonInterrupt, FALLING);
}
void loop() {
if (buttonPressed) {
buttonPressed = false;
Serial.println("Debounced button press detected");
}
}
This is a simple starting point, though button debouncing in interrupt code should still be handled carefully. In some designs, it is better to do the minimum possible in the ISR and debounce in the main loop.
The key lesson is that real signals are often messy, and interrupts should be designed with that in mind.
Using interrupts to count pulses
A very common and practical use of interrupts is counting pulses from a sensor, encoder, or external signal. If pulses arrive quickly, polling may miss them. An interrupt is often much more reliable.
Example:
const int pulsePin = 14;
volatile unsigned long pulseCount = 0;
void IRAM_ATTR countPulse() {
pulseCount++;
}
void setup() {
Serial.begin(115200);
pinMode(pulsePin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(pulsePin), countPulse, RISING);
}
void loop() {
static unsigned long lastPrintTime = 0;
if (millis() - lastPrintTime >= 1000) {
lastPrintTime = millis();
noInterrupts();
unsigned long count = pulseCount;
interrupts();
Serial.print("Pulse count: ");
Serial.println(count);
}
}
This example also introduces another important concept: protecting shared data when reading it.
The program briefly disables interrupts while copying the shared pulseCount variable. That helps prevent inconsistent reads if the ISR updates the variable at the same moment.
For simple types on some architectures, this may not always be strictly necessary, but it is a useful and safe pattern when dealing with shared interrupt data.
What timer interrupts are
A timer interrupt happens when a hardware timer reaches a configured value and triggers an ISR. This is different from a GPIO interrupt, which is triggered by an external signal change.
Timer interrupts are useful when you want regular timing independent of the main loop. Instead of checking millis() constantly, a hardware timer can trigger code at precise intervals.
For example, you might want an action every 100 milliseconds. A timer can be configured to generate that event automatically.
This can be especially useful for periodic sampling, regular state updates, or repetitive control actions.
A basic timer interrupt example
A simple Arduino-style ESP32 timer example looks like this:
hw_timer_t *timer = NULL;
volatile bool timerFlag = false;
void IRAM_ATTR onTimer() {
timerFlag = true;
}
void setup() {
Serial.begin(115200);
timer = timerBegin(0, 80, true);
timerAttachInterrupt(timer, &onTimer, true);
timerAlarmWrite(timer, 1000000, true);
timerAlarmEnable(timer);
}
void loop() {
if (timerFlag) {
timerFlag = false;
Serial.println("Timer interrupt fired");
}
}
This example creates a hardware timer, attaches an ISR, sets the alarm value, and enables repeated interrupts.
The exact timer API can vary depending on the framework version you are using, but the concept remains the same. A hardware timer is configured, and when it reaches the chosen interval, the ISR runs.
As with GPIO interrupts, the ISR here only sets a flag. The main loop handles the serial output.
Why timer interrupts can be better than delay()
Beginners often use delay() to create repeated behavior. That works for simple blinking examples, but it blocks the main loop.
For example:
void loop() {
digitalWrite(2, HIGH);
delay(1000);
digitalWrite(2, LOW);
delay(1000);
}
This blinks an LED, but during each delay the program is mostly waiting.
A timer-based design can separate timing from the main logic. The program no longer has to sit in delays just to keep track of time. That makes it easier to combine multiple tasks and keep the program responsive.
In many projects, a non-blocking millis() approach is enough. But when precise repeated timing or event-driven structure is needed, hardware timers become very useful.
Timers versus millis()
It is worth understanding that not every repeated task needs a hardware timer interrupt. In many ordinary ESP32 projects, checking elapsed time with millis() is simpler and safer.
Example:
unsigned long lastTime = 0;
void loop() {
if (millis() - lastTime >= 1000) {
lastTime = millis();
Serial.println("One second passed");
}
}
This is often the best first choice for periodic tasks that are not extremely timing-sensitive.
Hardware timers are more useful when you need stronger timing regularity, faster periodic actions, or a design where an interrupt-based event makes more sense.
A good general rule is this:
Use millis() for many normal repeated jobs.
Use hardware timers when true timer interrupts provide a clear advantage.
Shared data between ISR and main code
Whenever an ISR and the main code both access the same variable, you need to think carefully about data safety.
Common good practices include:
Mark shared variables as volatile.
Keep shared data small and simple when possible.
Copy shared values safely before using them in longer operations.
Avoid modifying the same complex data structure from both the ISR and the main code.
For example, this is a good pattern:
volatile unsigned long eventCount = 0;
void IRAM_ATTR onEvent() {
eventCount++;
}
void loop() {
unsigned long countCopy;
noInterrupts();
countCopy = eventCount;
interrupts();
Serial.println(countCopy);
delay(1000);
}
This reduces the chance of inconsistent behavior caused by interrupt timing.
Things you should not do inside an ISR
This is one of the most important parts of working with interrupts on ESP32. Avoid doing heavy work inside the ISR.
In general, do not do the following inside an ISR unless you know exactly what the framework and hardware allow:
- Do not call
delay(). - Do not perform long loops.
- Do not allocate memory.
- Do not do large string operations.
- Do not perform slow peripheral work.
- Do not place lots of serial printing in the ISR.
The reason is reliability. An ISR should be quick and predictable. If it becomes too heavy, it can disrupt other system behavior and make bugs much harder to diagnose.
The safest design is usually:
- Interrupt happens.
- ISR records the event.
- Main code handles the detailed response.
A practical timer example: blinking without blocking
Here is a simple idea that shows how a timer interrupt can trigger an action flag while the main loop remains free:
hw_timer_t *timer = NULL;
volatile bool ledToggle = false;
const int ledPin = 2;
bool ledState = false;
void IRAM_ATTR onTimer() {
ledToggle = true;
}
void setup() {
Serial.begin(115200);
pinMode(ledPin, OUTPUT);
timer = timerBegin(0, 80, true);
timerAttachInterrupt(timer, &onTimer, true);
timerAlarmWrite(timer, 500000, true);
timerAlarmEnable(timer);
}
void loop() {
if (ledToggle) {
ledToggle = false;
ledState = !ledState;
digitalWrite(ledPin, ledState);
}
Serial.println("Main loop still free");
delay(1000);
}
The LED toggles according to the timer interrupt, while the main loop continues doing other work.
This demonstrates a key advantage: timed behavior can exist without forcing the whole application into blocking delays.
Interrupt priorities and complexity
As projects become more advanced, you may encounter topics such as interrupt priorities, multiple cores, and framework-specific behavior. Those topics matter in more complex systems, but beginners do not need to start there.
The main thing to understand first is that interrupts are powerful because they let important events cut in line. That power should be used carefully. A simple, short ISR with clear shared-data handling is much better than a clever but complicated design that becomes unstable.
Common beginner mistakes
One common mistake is putting too much code in the ISR. This is probably the biggest one.
Another mistake is forgetting volatile on shared variables. That can cause confusing behavior where the main code seems not to see ISR updates correctly.
A third mistake is using interrupts for tasks that do not really need them. Some problems are better solved with a normal loop and millis() timing. Not every project becomes better just because interrupts are involved.
Another common issue is failing to handle noisy inputs such as buttons properly. A bouncing button can trigger several interrupts for one press.
Beginners also sometimes forget that shared variables may need safe access protection, especially when larger data types or multi-step operations are involved.
Finally, timer APIs can vary between environments and versions, so it is important to stay consistent within your chosen framework and test carefully.
When to use interrupts and timers
Interrupts are a good choice when an external event needs quick attention or when polling might miss important changes. They are especially useful for pulse detection, quick input response, and signal timing.
Timers are a good choice when you need actions to happen at regular intervals with more direct hardware-based timing. They are especially helpful for repeated sampling, timed control, or periodic state updates.
But simplicity still matters. If a task works well with a non-blocking millis() check, that may be the better solution. The best embedded code is often not the most advanced code. It is the clearest code that reliably meets the project’s needs.
Conclusion
Timers and interrupts are essential tools for building responsive ESP32 projects. Interrupts let the microcontroller react quickly to important events, while timers allow tasks to happen at defined intervals without relying entirely on blocking delays or constant polling.
The most important lessons are straightforward. Keep ISRs short. Use volatile for shared variables. Let the ISR record the event and let the main code do the heavier work. Use timer interrupts when regular hardware-based timing is truly helpful, and use simpler non-blocking approaches when they are enough.