Execution Modes
The mode parameter controls what happens when a trigger fires while a prior
invocation of the same handler is still running. Four modes are available,
matching Home Assistant's automation mode names.
await self.bus.on_state_change(
"binary_sensor.front_door",
changed_to="on",
handler=self.on_door_opened,
name="front_door_opened",
mode="single", # or "restart", "queued", "parallel"
)
All four bus registration methods — on_state_change, on_attribute_change,
on_call_service, and on() — accept mode=.
The Four Modes
single — drop while running (app default)
single is the default for app handlers. When a trigger fires while the prior
invocation is still running, the bus drops the re-fire. The running invocation
continues uninterrupted.
class LockApp(App[AppConfig]):
async def on_initialize(self):
# App handlers default to single — no mode= needed.
await self.bus.on_state_change(
"binary_sensor.front_door",
changed_to="on",
handler=self.on_door_opened,
name="front_door_opened",
)
async def on_door_opened(
self,
new: D.StateNew[states.BinarySensorState],
):
# Only one copy of this handler runs at a time.
# A second trigger while this is still running is dropped.
await self.api.call_service("lock", "lock", entity_id="lock.front")
D.StateNew[...] is dependency injection — Hassette extracts and converts the
new state into the handler parameter automatically. See
Writing Handlers for the full annotation reference.
The re-fire is logged at DEBUG. No WARNING is emitted — this is expected behavior, not an error. The running handler sees no interruption.
single is the right choice for handlers that mutate shared state, call a slow
service, or hold a resource. One invocation at a time prevents state corruption
and duplicate side effects.
restart — cancel and replace
restart cancels the running invocation when a new trigger arrives, then
starts a fresh one with the new event.
class SearchApp(App[AppConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
"input_text.search_query",
handler=self.on_query_change,
name="search_query",
mode="restart",
)
async def on_query_change(
self,
new: D.StateNew[states.InputTextState],
):
# If the user types again before this finishes, the in-flight
# search is cancelled and a new one starts with the latest query.
await self.api.call_service(
"search",
"query",
entity_id="sensor.search",
query=str(new.value),
)
self.logger.info("Query sent for: %s", new.value)
The cancelled invocation receives CancelledError at its next await. Making
handlers cancellation-safe is the author's responsibility. The framework cancels
the task; try/finally runs cleanup as the cancellation propagates. Suppressing
the CancelledError keeps the task alive and defeats the restart.
restart is the right choice for "latest wins" patterns: a search-as-you-type
handler, a preview renderer, or any scenario where only the most recent trigger
matters.
Cancelled invocations have side effects
A handler cancelled mid-run may have already mutated state or called a
service. The framework provides no automatic rollback. Handlers that mutate
state mid-run need cancellation handling with try/finally. single or
queued avoid partial execution entirely.
queued — serialize in arrival order
queued runs every trigger, one at a time, in the order they arrived. Triggers
that arrive while an invocation is running are held and dispatched sequentially
after the current invocation completes.
class AuditApp(App[AppConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
"input_boolean.trigger_audit",
changed_to="on",
handler=self.run_audit,
name="audit_trigger",
mode="queued",
)
async def run_audit(self):
# Every trigger runs in the order it arrived.
# If the queue reaches 10 pending triggers, the newest is dropped.
self.logger.info("Starting audit run")
await self.api.call_service(
"script", "turn_on", entity_id="script.run_audit"
)
The queue holds at most 10 pending triggers. When the queue is full, the newest trigger is dropped and logged at DEBUG. Already-queued triggers are unaffected. This cap prevents unbounded memory growth on high-frequency triggers feeding a slow handler.
queued is the right choice when every event must be processed and the order
matters: audit logging, sequential command dispatch, or anything where skipping
an event would leave the system in an incorrect state.
parallel — concurrent (framework default)
parallel imposes no overlap guard. Multiple invocations of the same listener
run concurrently. This is the behavior that all handlers had before execution
modes were introduced, and it is what framework-internal listeners default to.
class MetricApp(App[AppConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
"sensor.*",
handler=self.record_reading,
name="all_sensors",
mode="parallel",
)
async def record_reading(
self,
entity_id: D.EntityId,
):
# Each sensor event spawns an independent recording task.
# Multiple readings are logged concurrently.
self.logger.info("Sensor reading from %s", entity_id)
parallel is the right choice for stateless, idempotent handlers, or handlers
where each invocation manages its own isolated resources.
Default Mode: Tier-Aware
The default mode depends on the tier — who registered the listener. App handlers and framework-internal listeners get different defaults.
Framework-internal listeners are the bus subscriptions Hassette registers for
itself — not through self.bus.* in an app — to run its own services.
| Registration tier | Default mode | Why |
|---|---|---|
App handler (self.bus.*) |
single |
Prevents a handler from running twice at once in user automations |
| Framework-internal listener | parallel |
Preserves concurrent behavior required by the framework |
An explicit mode= always overrides the tier default.
Framework-internal listeners — the service supervisor, the state cache, the runtime query service — depend on concurrent dispatch. The tier split mirrors Home Assistant, where automation modes apply to user automations only.
Migrating from pre-1.0 concurrent behavior
Before execution modes were introduced, all handlers ran concurrently. An app handler that relied on that behavior can restore it explicitly:
await self.bus.on_state_change(
"sensor.outdoor_temperature",
handler=self.record_reading,
name="temp_readings",
mode="parallel", # opt back into pre-1.0 concurrent behavior
)
Observability
Suppressed and dropped counts
Each listener with a non-parallel mode tracks two live counters:
- Suppressed — triggers dropped by
singlewhile the handler was running. - Dropped — triggers discarded by
queuedwhen the queue was at its cap.
These counts appear in the monitoring UI's Handlers tab when non-zero. They are live-only diagnostics — held in memory, reset to zero when the process restarts, never persisted to the database.
A non-zero suppressed count on a single handler indicates re-fires are
arriving faster than the handler completes. If that represents lost work,
consider queued. If it represents expected deduplication, single is correct.
Cancelled counts
restart cancels the running invocation on every re-fire, so a busy restart
handler accumulates cancelled invocations. Cancelled is a persisted execution
status, not a live counter. The telemetry database records each one, and the
monitoring UI surfaces a cancelled count separate from failures.
A restart handler working as designed shows a high cancelled count and a
100% success rate. Cancellation is the intended outcome, so it never counts
against the success rate.
The handler detail pane marks each cancelled invocation with a blue diamond.
A cancelled label sets it apart from the red square of an error.
Stall detection
A handler that holds its execution-mode guard — any mode except parallel —
longer than 60 seconds without completing emits a WARNING. This is the only
WARNING the execution mode feature generates. Suppressed and dropped events
always log at DEBUG.
The per-listener timeout still applies and ultimately releases the guard when
it fires. The stall WARNING is an early signal, independent of the timeout.
Mode in the monitoring UI
The mode is persisted for each listener and displayed as a chip in the app detail Handlers tab. The mode chip is visible alongside invocation counts and last-seen timestamps.
Composition
Each parameter shown with mode below — debounce, throttle, once, and
duration — is documented in full in Subscription Methods.
With debounce and throttle
Rate limiting (debounce, throttle) and mode operate at different points
in the dispatch pipeline.
- Rate limiting governs whether an invocation starts — debounce delays start until quiet, throttle limits start frequency.
modegoverns what happens when a started invocation overlaps with the next trigger.
Both can be active at the same time and do not conflict:
class TempApp(App[AppConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
"sensor.outdoor_temperature",
handler=self.on_temp_change,
name="outdoor_temp",
debounce=5.0,
mode="single",
)
async def on_temp_change(
self,
new: D.StateNew[states.SensorState],
):
# debounce governs whether an invocation starts (waits 5 s of quiet).
# single governs overlap: if the handler is still running when the
# next debounced call fires, that call is dropped.
self.logger.info("Temperature settled at %s", new.value)
A debounced trigger that finally fires starts an invocation. If the handler
is still running when the next debounced trigger fires, single drops it.
restart would cancel and replace. The two mechanisms compose naturally.
With once=True
A once=True listener fires at most once. The once-guard short-circuits in
the dispatch path before the mode guard runs — a concurrent re-fire hits the
once-check and returns immediately, never reaching the mode logic. mode has
no behavioral effect when combined with once=True.
class StartupApp(App[AppConfig]):
async def on_initialize(self):
# once=True fires at most once. The once-guard short-circuits before
# the mode guard runs — mode has no effect when once=True.
await self.bus.on_state_change(
"binary_sensor.front_door",
changed_to="on",
handler=self.on_first_open,
name="first_door_open",
once=True,
)
async def on_first_open(self):
self.logger.info("Front door opened for the first time")
With duration
Duration listeners fire their handler only after the state has held for the configured period. The mode guard applies at that delayed dispatch point — when the hold elapses and the handler is called — not when the trigger first arrives.
class OccupancyApp(App[AppConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
"binary_sensor.motion_sensor",
changed_to="on",
duration=30.0,
handler=self.on_sustained_motion,
name="sustained_motion",
mode="single",
)
async def on_sustained_motion(self):
# The guard applies when the 30-second hold expires and the
# handler actually dispatches — not when the trigger first arrives.
await self.api.call_service(
"light", "turn_on", entity_id="light.living_room"
)
A second motion event arriving while the first hold timer is running resets
the timer. If two hold timers expire close together and the handler is still
running from the first, single drops the second dispatch.
See Also
- Subscription Methods: full parameter reference, including
debounce,throttle,once,timeout,duration, andif_exists - Writing Handlers: handler patterns and dependency injection
- Filtering & Predicates:
where=predicates and conditions

