Skip to content

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 single while the handler was running.
  • Dropped — triggers discarded by queued when 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.

Handler strip showing a cancelled count beside a 100% 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.

Restart handler detail with cancelled invocations

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.
  • mode governs 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