Advanced Topics
This section covers advanced component development topics in ESPHome. These features are typically used in more complex components or when optimizing for performance and resource usage.
Component Loop Control
Section titled “Component Loop Control”ESPHome’s main loop runs at the configured loop_interval_ (default ~16 ms, ~62.5 Hz / ~3750 component-phase calls per minute), calling each registered component’s loop() method. This is fast enough to feel responsive but still wastes CPU cycles for components that don’t need continuous updates. The loop control API allows components to dynamically enable or disable their participation in the main loop.
NOTE
Cadence change in 2026.5.0
Before PR #15792, loop() could be emergently pulled forward to roughly double the configured rate when the scheduler had a timer due sooner than loop_interval_/2. Configs without scheduler activity weren’t affected, but on devices with set_interval, set_timeout, or PollingComponent updates running at sub-loop_interval_ cadences (common on busy ESP32 builds), every component’s loop() ran at roughly double the documented rate. As of 2026.5.0, components run at the configured loop_interval_ exactly, and App.set_loop_interval() actually saves power. See “Choosing Between loop() and the Scheduler” below for what this means for periodic work.
On platforms with socket select support (ESP32, Host, and LibreTiny-based chips like BK72xx/RTL87xx), the loop also wakes up immediately when there is new data on monitored sockets (such as API connections and OTA updates), ensuring low-latency network communication without polling. ESP8266 and RP2040 platforms use a simpler TCP implementation without select support.
Overview
Section titled “Overview”Components can control their loop execution using three methods:
disable_loop()- Removes the component from active loop executionenable_loop()- Re-adds the component to active loop executionenable_loop_soon_any_context()- Thread-safe version that can be called from ISRs, timers, or other threads
When to Use Loop Control
Section titled “When to Use Loop Control”Loop control is beneficial for:
- Event-driven components - Components that respond to interrupts or callbacks rather than polling
- Conditional components - Components that only need to run under specific conditions
- One-time operations - Components that complete their work and no longer need updates
- Power optimization - Reducing CPU usage for battery-powered devices
Basic Usage
Section titled “Basic Usage”Disabling the Loop
Section titled “Disabling the Loop”Components typically disable their loop when they have no work to do:
void MyComponent::loop() { if (!this->has_work()) { this->disable_loop(); return; }
// Do actual work this->process_data();}Re-enabling the Loop
Section titled “Re-enabling the Loop”Components re-enable their loop when new work arrives:
void MyComponent::on_data_received() { this->data_available_ = true; this->enable_loop(); // Resume loop processing}Thread-Safe Enabling from ISRs
Section titled “Thread-Safe Enabling from ISRs”When called from interrupt handlers, timers, or other threads, use the thread-safe version:
void IRAM_ATTR MyComponent::gpio_isr_handler() { // ISR context - cannot use enable_loop() directly this->enable_loop_soon_any_context();}See Waking from ISR for per-platform notes.
Real-World Examples
Section titled “Real-World Examples”1. Interrupt-Driven GPIO Binary Sensor
Section titled “1. Interrupt-Driven GPIO Binary Sensor”void GPIOBinarySensor::loop() { if (this->use_interrupt_) { if (this->interrupt_triggered_) { // Process the interrupt bool state = this->pin_->digital_read(); this->publish_state(state); this->interrupt_triggered_ = false; } else { // No interrupt, disable loop until next one this->disable_loop(); } } else { // Polling mode - always check this->publish_state(this->pin_->digital_read()); }}
// ISR handlervoid IRAM_ATTR gpio_interrupt_handler(void *arg) { GPIOBinarySensor *sensor = static_cast<GPIOBinarySensor *>(arg); sensor->interrupt_triggered_ = true; sensor->enable_loop_soon_any_context();}2. State-Based Component (BLE Client)
Section titled “2. State-Based Component (BLE Client)”void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { this->set_state(espbt::ClientState::INIT); return; }
if (this->state_ == espbt::ClientState::INIT) { // Register with BLE stack auto ret = esp_ble_gattc_app_register(this->app_id); if (ret) { ESP_LOGE(TAG, "gattc app register failed. app_id=%d code=%d", this->app_id, ret); this->mark_failed(); } this->set_state(espbt::ClientState::IDLE); } // READY_TO_CONNECT means we have discovered the device else if (this->state_ == espbt::ClientState::READY_TO_CONNECT) { this->connect(); } // If idle, disable loop as set_state will enable it again when needed else if (this->state_ == espbt::ClientState::IDLE) { this->disable_loop(); }}
void BLEClientBase::set_state(espbt::ClientState st) { ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); ESPBTClient::set_state(st); if (st == espbt::ClientState::READY_TO_CONNECT) { // Enable loop when we need to connect this->enable_loop(); }}3. Network Event Handler (Ethernet)
Section titled “3. Network Event Handler (Ethernet)”void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event, void *event_data) { switch (event) { case ETHERNET_EVENT_START: global_eth_component->started_ = true; global_eth_component->enable_loop_soon_any_context(); break; case ETHERNET_EVENT_DISCONNECTED: global_eth_component->connected_ = false; global_eth_component->enable_loop_soon_any_context(); break; }}4. One-Time Task (Safe Mode)
Section titled “4. One-Time Task (Safe Mode)”void SafeModeComponent::loop() { if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) { // Boot successful, no need to monitor anymore ESP_LOGI(TAG, "Boot successful; disabling safe mode checks"); this->clean_rtc(); this->boot_successful_ = true; this->disable_loop(); // Never need to check again }}Implementation Details
Section titled “Implementation Details”Component State Management
Section titled “Component State Management”Components track their loop state using internal flags:
COMPONENT_STATE_LOOP- Component is actively loopingCOMPONENT_STATE_LOOP_DONE- Component has disabled its loop
Performance Considerations
Section titled “Performance Considerations”With the main loop running at the configured loop_interval_ (default 16 ms / ~62.5 Hz):
- An idle component with an empty
loop()still consumes CPU cycles each tick - 10 disabled components save ~37,500 function calls per minute (default cadence)
- Critical for ESP8266/ESP32 devices with limited CPU resources, and for power-managed configurations using
App.set_loop_interval()
Thread Safety
Section titled “Thread Safety”The enable_loop_soon_any_context() method is specifically designed for cross-thread usage:
- Sets volatile flags that are checked by the main loop
- No memory allocation or complex operations
- Safe to call from ISRs, timer callbacks, or FreeRTOS tasks on any platform where ISRs are supported
- Multiple calls are idempotent (safe to call repeatedly)
Best Practices
Section titled “Best Practices”-
Only disable your own loop - Components should only call loop control methods on themselves, never on other components
-
Clear state before disabling - Ensure the component is in a clean state before disabling the loop
-
Document loop dependencies - If your component requires continuous looping, document why
-
Prefer event-driven design - Use interrupts, callbacks, or timers instead of polling when possible
-
Test edge cases - Verify behavior when rapidly enabling/disabling the loop
Common Patterns
Section titled “Common Patterns”Conditional Compilation
Section titled “Conditional Compilation”Some components disable their loop based on compile-time configuration:
void CaptivePortal::loop() {#ifdef USE_ARDUINO if (this->dns_server_ != nullptr) { this->dns_server_->processNextRequest(); } else { this->disable_loop(); }#else // No DNS server on ESP-IDF this->disable_loop();#endif}BLE Components
Section titled “BLE Components”Most BLE components inherit from parent classes (like BLEClientNode) that have a loop() method, but don’t actually need it. They disable their loop immediately since all work is done through BLE callbacks and periodic update() calls:
void Anova::loop() { // Parent BLEClientNode has a loop() method, but this component uses // polling via update() and BLE callbacks so loop isn't needed this->disable_loop();}Debugging
Section titled “Debugging”To debug loop control issues:
-
Add logging when enabling/disabling:
ESP_LOGV(TAG, "%s loop disabled", this->get_component_source()); -
Check if your component is in the loop state:
bool is_looping = this->is_in_loop_state(); -
Use the Logger component’s async support as a reference implementation
Choosing Between loop() and the Scheduler
Section titled “Choosing Between loop() and the Scheduler”ESPHome offers several primitives for periodic or deferred work. Since the main-loop cadence fix in 2026.5.0 (PR #15792), the tradeoffs have shifted enough that some older patterns are now pessimizations.
Quick rule of thumb
Section titled “Quick rule of thumb”| Cadence / shape | Recommended primitive |
|---|---|
Sub-loop_interval_ (< 16 ms) wakes | HighFrequencyLoopRequester |
| Periodic, interval < 250 ms | loop() + App.get_loop_component_start_time() gate |
| Periodic, 250–500 ms | Either; loop() usually still cheaper |
| Periodic, ≥ 500 ms | set_interval |
| One-shot or rare reschedule | set_timeout |
| Run-once-on-next-loop | defer |
Crossover depends on how cheap your loop() “nothing to do” path is and whether the timer interval would force extra Phase A wakes. Use App.get_loop_component_start_time() rather than millis() — it’s cached per tick.
Why loop() wins for sub-second work
Section titled “Why loop() wins for sub-second work”Each main-loop tick has two phases. Phase A runs every tick (drains wake notifications, runs due scheduler entries, feeds the watchdog). Phase B runs the component list only when loop_interval_ elapsed, HighFrequencyLoopRequester is active, or a wake_loop_* raised the flag. A set_interval(N) with N smaller than (or unaligned with) loop_interval_ forces extra Phase A wakes that each pay scheduler-dispatch + WDT-feed cost — often more than the work itself.
RAM cost
Section titled “RAM cost”set_timeout, set_interval, and defer store callbacks in std::function. With libstdc++ on 32-bit MCUs (the configuration ESPHome targets), the small-buffer optimization holds ~8 bytes of capture inline; larger captures spill to heap allocation, fragmenting the heap over time. Other standard libraries differ, but ESPHome builds are GCC + libstdc++ in practice. Each registration adds its own std::function — three timers means three callback objects.
this->set_interval(1000, [this]() { this->tick_(); }); // SBO-safe (one pointer)this->set_interval(1000, [this, addr, name]() { this->ping_(...); }); // heap-allocated (> 8 bytes)A loop() method costs one pointer in the application’s looping_components_ list per active component (regardless of how many distinct tasks the loop body performs) and no per-call allocation — this is already available. So a component doing three things at three different cadences via gated branches in loop() is one pointer of overhead; the same work split across three set_interval registrations is three std::functions plus their scheduler-entry storage.
Other primitives
Section titled “Other primitives”set_intervalstill wins for intervals ≥ 500 ms, when many items share one cadence, or when you need named cancellation.set_timeout— one-shots and self-rescheduling timers with variable delays. Don’t chain it as a hand-rolledset_interval.defer— run-once on the next main-loop iteration; use it to break recursion or escape interrupt context, not as a task queue.
Naming timers
Section titled “Naming timers”Give a timer an id only when it must be cancelled or replaced. Use a static constexpr uint32_t, not a string: a string
costs RAM on ESP8266 and is compared character by character.
Ids are scoped to the component instance that registers the timer. They never clash with other components, other instances of the same component, or ESPHome’s own timers, so there is no need for long or prefixed names: plain numbers starting at 0 are fine. Keep all of a component’s ids together in one place so they stay unique within it.
static constexpr uint32_t READ_TIMEOUT_ID = 0;static constexpr uint32_t RETRY_INTERVAL_ID = 1;
this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); });this->cancel_timeout(READ_TIMEOUT_ID);The Loop Blocking Warning
Section titled “The Loop Blocking Warning”Every component loop(), update() and scheduled callback runs under a guard that measures how long it held the main loop; setup() is not measured, its time is only reported in the Setup ... took N ms line. When one pass exceeds the threshold (WARN_IF_BLOCKING_OVER_MS, 50 ms by default) the log shows:
[W][component:XXX]: wifi took a long time for an operation (73 ms), max is 50 msThe threshold ratchets: after a warning the component’s own threshold becomes the measured time plus 10 ms, rounded down to a 10 ms step, so a component that blocks once for 73 ms is only reported again when it blocks for more than 80 ms, for the rest of the run. The warning is the main tool for finding work that should be a state machine, a cached value or a set_timeout; fix the cause rather than the message.
UnavoidableBlockingScope
Section titled “UnavoidableBlockingScope”A few steps done from a loop pass have no shorter form and cannot be split across passes: turning on a radio, the first Wi-Fi connect, a key generation whose cost is the algorithm itself. Wrapping only that step in an UnavoidableBlockingScope (from esphome/core/application.h) leaves it out of the measurement for the current pass:
void MyComponent::loop() { if (this->needs_key_) { UnavoidableBlockingScope scope; this->generate_key_(); // 60 ms of arithmetic, nothing to split } this->poll_(); // still measured}When the scope ends it moves the pass start time forward by the time spent inside it, so the guard still reports everything else in the pass and the component’s threshold does not ratchet up over the one step nothing can be done about. Code after the scope that reads App.get_loop_component_start_time() sees the adjusted time, which is closer to “now” than the original pass start; elapsed time computed from it across the scope leaves out the scoped work, so read millis() when the true elapsed time matters. Scopes may nest; the outermost one decides how much of the pass is left out.
Never use it to paper over a problem that can be solved. A slow driver call, a loop that could be a state machine, a computation that could be cached or deferred, a blocking read that could be polled: those are what the warning exists to find, and wrapping them hides the bug instead of fixing it. If in doubt, leave the warning in.
WARNING
- Only work timed by the guard is affected: a component’s
loop()orupdate(), or a scheduler callback. The scope does nothing insetup(). - Use the scope from the main loop task only.
- The watchdog is not fed inside the scope, so the work must finish within the watchdog timeout, or be paired with a
watchdog::WatchdogManager(fromesphome/components/watchdog/watchdog.h) that raises the timeout for the same stretch. Add"watchdog"to the component’sAUTO_LOADto use it; it compiles on every platform but only changes the timeout on ESP32 and RP2040.
Waking the Main Loop from Background Threads
Section titled “Waking the Main Loop from Background Threads”For components that receive events in background threads/FreeRTOS tasks (BLE callbacks, network events, platform callbacks, etc.) and need low-latency processing, use App.wake_loop_threadsafe() to immediately wake the main loop instead of waiting 0-16ms for the next loop timeout.
Platform Support: Available on all platforms. No setup or opt-in required — it just works.
- ESP32/LibreTiny: FreeRTOS task notifications (<1 µs)
- ESP8266:
esp_schedule()to exitesp_delay()early - RP2040: ARM
__sev()to exit__wfe()early - Zephyr (nRF52):
k_sem_give()to exitk_sem_take()early - Host: UDP loopback socket to wake
select()
Call from background thread/FreeRTOS task context:
#include "esphome/core/application.h"
void MyComponent::background_callback(Event event) { this->pending_events_.push_back(event);
// Only wake for time-critical events if (event.is_time_critical()) { App.wake_loop_threadsafe(); }}No #ifdef guards, no socket dependency, no AUTO_LOAD — just call it.
When to wake:
- Interactive events (user pairing, passkey requests) — need immediate response
- Time-critical operations — latency matters for correctness
- Low-frequency events — won’t cause wake storms
When NOT to wake:
- High-frequency events (scan results, RSSI reads) — avoid notification storms
- Non-time-critical operations — can wait for next loop iteration
- Events that batch well — queue multiple before processing
Waking from ISR
Section titled “Waking from ISR”For ISR handlers (e.g. UART RX ISR, GPIO ISR), use enable_loop_soon_any_context() on your component, or App.wake_loop_any_context() if you just need to wake the loop. Both auto-detect ISR vs task context. Platform notes:
- ESP32: ISR-safe via
vTaskNotifyGiveFromISR(). A separateApp.wake_loop_isrsafe()is also available when you already know you are in ISR context and want to forward thexHigherPriorityTaskWokenflag yourself. - ESP8266: ISR-safe via
esp_schedule(), which is IRAM.App.wake_loop_isrsafe()is also available; it takes no arguments since ESP8266 has no FreeRTOS task to notify, and the body is implemented inline and typically inlines into theIRAM_ATTRcaller. For ISR safety, ensure the called code is IRAM-resident. - RP2040: ISR-safe. The wake body is inlined into
enable_loop_soon_any_context(), which is placed in.time_criticalRAM viaIRAM_ATTR. - LibreTiny: ISR-safe via
vTaskNotifyGiveFromISR()(same path as ESP32);App.wake_loop_isrsafe()is also available.IRAM_ATTRplaces handlers in executable RAM as expected, except on BK72xx where it is a no-op — the Beken SDK wraps every flash op inGLOBAL_INT_DISABLE()(FIQ+IRQ masked), so flash-resident ISR handlers are safe. - Zephyr (nRF52): ISR-safe via
k_sem_give(), which the Zephyr kernel allows from ISR context.App.wake_loop_isrsafe()is also available, takes no arguments (k_sem_give()handles ISR scheduling internally), and does not requireIRAM_ATTR— nRF52 runs ISRs directly from internal flash.
Example ISR using the explicit ISR-safe API on ESP32:
void IRAM_ATTR MyComponent::gpio_isr(MyComponent *arg) { arg->pending_ = true; BaseType_t xHigherPriorityTaskWoken = pdFALSE; App.wake_loop_isrsafe(&xHigherPriorityTaskWoken); if (xHigherPriorityTaskWoken) portYIELD_FROM_ISR();}Hiding Entities at Boot
Section titled “Hiding Entities at Boot”Prefer the internal: YAML key; it is guaranteed and has none of the limitations below. When the decision can only be made at boot, EntityBase::set_internal(bool) may be called from on_boot at its default priority or from a component’s setup() that runs before setup_priority::AFTER_WIFI (a numerically higher priority). Requires ESPHome 2026.10.0 or later (PR #19069). Calls after setup are unsupported: the flag is still written and an error is logged, and from 2027.3.0 the call is ignored.
globals: - id: second_unit_installed type: bool restore_value: true initial_value: "false"
esphome: on_boot: then: - lambda: id(unit_2_temperature).set_internal(!id(second_unit_installed));Waiting for a device handshake
Section titled “Waiting for a device handshake”If the answer comes from the device itself, hold setup with can_proceed() until it arrives. Application::setup() keeps running the loops of components already set up while it waits, so the handshake can proceed, and the API and MQTT have not run yet when the flag is written. The component must keep the default setup_priority::DATA or another priority before AFTER_WIFI. Always add a timeout so a missing device does not block boot.
void MyClimate::setup() { this->handshake_started_ = App.get_loop_component_start_time(); this->start_handshake_();}
void MyClimate::loop() { this->process_uart_data_(); }
// Called from process_uart_data_() once the device has reported its featuresvoid MyClimate::on_features_(const Features &features) { this->zone_2_switch_->set_internal(!features.zones); this->features_known_ = true;}
bool MyClimate::can_proceed() { // Give up after 2 s; entities then keep their YAML internal: value return this->features_known_ || App.get_loop_component_start_time() - this->handshake_started_ > 2000;}Known limitations
Section titled “Known limitations”These are not bugs, so please do not open issue reports for them; a PR that removes one with no RAM or performance cost would be considered.
- Consumers are not notified, so the flag is decided once per boot.
- After
setup_priority::AFTER_WIFIthe API camera listener has already read the flag; aftersetup_priority::AFTER_CONNECTION, MQTT has too. - Un-hiding a YAML
internal: trueentity skips the duplicate name check, and Zigbee never registers it.
Persisting Component State
Section titled “Persisting Component State”A component that must survive a reboot stores a small struct through global_preferences. The key must be stable
across builds and unique per instance; a fixed hash combined with one passed from code generation (the globals
component hashes its id) does that. Entities use make_entity_preference<T>() instead, which derives the key
from the entity.
this->pref_ = global_preferences->make_preference<MyState>(fnv1_hash("my_component") ^ this->id_hash_, in_flash);if (!this->pref_.load(&state)) state = MyState{}; // first boot, layout change or RTC content lostthis->pref_.save(&state);Both calls return false on failure, and a failed load() is the normal path after a power loss on RTC storage.
The second argument picks the backend. false means RTC memory on chips that have it: it survives a software reboot
or a deep-sleep wake, not a power loss, and costs no flash wear, so it is the right choice for values that change
often. true means flash (NVS on ESP32), which survives a power loss but wears with every write.
Entities pass the same choice as the second argument of make_entity_preference<T>(version, in_flash). It derives the
same key as make_entity_preference<T>(version), which always uses the platform default backend, so adding a
storage: option to an existing entity keeps the values saved under the default for as long as the user leaves that
backend selected. The key is all that is shared: each backend keeps its own value and never reads the other’s, so
after a switch the entity loads whatever the newly selected backend holds. The first time that is nothing, like a
first boot, but switching back can bring up a value saved there earlier:
this->rtc_ = this->make_entity_preference<bool>(0, in_flash);Let the user choose with the storage: option from esphome/preferences.py instead of hard-coding the backend:
from esphome import preferencesfrom esphome.const import CONF_ID, CONF_STORAGEfrom esphome.helpers import fnv1_hash
CONFIG_SCHEMA = cv.Schema({...}).extend(preferences.storage_schema())
async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_id_hash(fnv1_hash(str(config[CONF_ID])))) cg.add(var.set_in_flash(preferences.is_in_flash(config[CONF_STORAGE])))On the ESP8266 the space for saved values is small and shared; a component that saves large values or one per entity reserves them so the total is checked, see Saved settings on the ESP8266.
storage_schema() keeps each platform’s historic default (RTC on ESP8266, flash elsewhere). A component that prefers
RTC whenever the chip has it resolves its own default with the two lower-level helpers:
def _storage_default(value: ConfigType) -> ConfigType: if CONF_STORAGE not in value: if not preferences.rtc_supported(): raise cv.Invalid("This chip has no RTC memory; set 'storage: flash'", path=[CONF_STORAGE]) value[CONF_STORAGE] = preferences.STORAGE_RTC return value
CONFIG_SCHEMA = cv.All( cv.Schema({cv.Optional(CONF_STORAGE): preferences.validate_storage}), _storage_default,)rtc_supported() is true on every platform with an RTC-backed preferences backend (ESP8266, and ESP32 except the C2
and C61); call it from a validator or a callable default, not while the schema is built at import time, when the
target is not known yet. validate_storage accepts flash or rtc and rejects rtc where it is unavailable. is_in_flash() also
emits the define that enables the RTC backend on ESP32, so always route the chosen value through it in to_code.
See Also
Section titled “See Also”- Component Loop Control:
esphome/core/component.handesphome/core/component.cpp - Wake Loop Threadsafe: PR #11681
- Loop Blocking Warning and
UnavoidableBlockingScope: PR #19020 - Hiding Entities at Boot: PR #19069
- Persisting Component State:
esphome/preferences.pyandesphome/core/preferences.h - Socket Consumption API - For components that use network sockets
Copyright © 2026 ESPHome - A project from the Open Home Foundation