Debouncing and Coalescing Repeated Task Triggers in asyncio¶
Event-driven services trigger the same work far more often than they need to run it. A config file is saved five times in a second; a cache invalidation message arrives for every row in a batch update; a websocket client sends a resize event per animation frame. Starting a task per trigger is the obvious code and the wrong behaviour: in a test, 200 triggers arriving every 2 ms against a 30 ms rebuild started 200 rebuilds. A debouncer that waits for 50 ms of quiet ran it once; a coalescer that keeps at most one run in flight and one pending ran it 15 times and finished 30 ms after the last trigger. They solve different problems, and this guide builds both.
Prerequisites¶
- Python 3.11+, stdlib only.
- Timer handles, from using loop.call_later and timer handles.
- Supervised background tasks, from handling exceptions in fire-and-forget tasks.
1. Decide which behaviour you need¶
The two patterns answer different questions:
- Debounce: "run once, after the triggers stop." Each trigger resets a timer; the work runs only when no trigger has arrived for the quiet period. Right for file watchers, search-as-you-type, and anything where intermediate states are worthless.
- Coalesce: "run as often as you can, but never concurrently and never more than once per burst of triggers." A trigger marks the state dirty; a single worker runs, then runs again only if something marked it dirty meanwhile. Right for cache rebuilds, index refreshes and state syncs, where the latest state should be applied soon even during a sustained burst.
A debouncer under a trigger stream that never pauses never runs at all. A coalescer under the same stream runs back-to-back forever. Neither is wrong; they encode different freshness requirements.
2. Build a debouncer on call_later¶
A debouncer needs nothing more than a timer handle that every trigger cancels and replaces:
import asyncio
from collections.abc import Awaitable, Callable
class Debouncer:
def __init__(self, delay: float, fn: Callable[[], Awaitable[None]]) -> None:
self._delay, self._fn = delay, fn
self._timer: asyncio.TimerHandle | None = None
self._running: asyncio.Task | None = None
def trigger(self) -> None:
if self._timer is not None:
self._timer.cancel()
loop = asyncio.get_running_loop()
self._timer = loop.call_later(self._delay, self._fire)
def _fire(self) -> None:
self._timer = None
self._running = asyncio.create_task(self._fn(), name="debounced")
async def aclose(self) -> None:
if self._timer is not None:
self._timer.cancel()
if self._running is not None:
self._running.cancel()
await asyncio.gather(self._running, return_exceptions=True)
Cancelling and re-creating a TimerHandle is cheap — it only marks the handle cancelled, and the loop discards cancelled handles lazily when they reach the top of its heap. Under 200 triggers that is 199 dead handles waiting for removal, which the loop compacts once cancelled handles make up a large share of the heap.
Verified with 200 triggers at 2 ms intervals and a 50 ms delay: one run, starting 50 ms after the last trigger.
Verify: count runs under a burst; it should be one per quiet period, never one per trigger.
3. Bound the wait with a maximum delay¶
A pure debouncer can starve. If triggers keep arriving faster than the quiet period, the work never runs — a file that is appended to every 10 ms will never be reindexed with a 50 ms debounce. Add a maximum wait measured from the first trigger of the burst:
import time
class BoundedDebouncer(Debouncer):
def __init__(self, delay: float, max_wait: float, fn) -> None:
super().__init__(delay, fn)
self._max_wait = max_wait
self._first: float | None = None
def trigger(self) -> None:
now = time.monotonic()
if self._first is None:
self._first = now
remaining = self._max_wait - (now - self._first)
if self._timer is not None:
self._timer.cancel()
loop = asyncio.get_running_loop()
self._timer = loop.call_later(max(0.0, min(self._delay, remaining)), self._fire)
def _fire(self) -> None:
self._first = None
super()._fire()
With delay=0.05, max_wait=0.5, a continuous stream runs the work every 500 ms; a short burst still runs once, 50 ms after it ends. This is the shape most UI and file-watching libraries use, and the one to default to.
Verify: feed a continuous trigger stream for 2 s; the work should run four times, not zero.
4. Build a coalescer with a dirty flag¶
The coalescer runs at most one instance of the work at a time and guarantees one more run after any trigger that arrived during a run:
class Coalescer:
def __init__(self, fn: Callable[[], Awaitable[None]]) -> None:
self._fn = fn
self._dirty = False
self._task: asyncio.Task | None = None
def trigger(self) -> None:
self._dirty = True
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop(), name="coalesced")
async def _loop(self) -> None:
while self._dirty:
self._dirty = False # clear BEFORE running: triggers during the run re-set it
await self._fn()
async def wait_idle(self) -> None:
if self._task is not None:
await self._task
The single most important line is the ordering of clear-then-run. If the flag were cleared after fn() returned, a trigger that arrived mid-run would be wiped out, and the state it signalled would never be applied.
Measured with the same 200 triggers against a 30 ms rebuild: 15 runs, none overlapping, and the last one started after the final trigger — so the final state was always applied. The burst lasted about 430 ms and everything was done at 458 ms.
Verify: assert inside fn that no other instance is running, and assert after the burst that the last run started after the last trigger.
5. Shut both down without losing the last trigger¶
At shutdown, both patterns can hold unfinished work: a pending timer in the debouncer, a dirty flag in the coalescer. Decide whether the last state must be applied:
async def shutdown(debouncer: Debouncer, coalescer: Coalescer, flush: bool) -> None:
if flush:
if debouncer._timer is not None: # run the pending work now
debouncer._timer.cancel()
debouncer._fire()
await asyncio.wait_for(coalescer.wait_idle(), timeout=5)
await debouncer.aclose()
Flushing is right for state that must be persisted — an index, a saved file. Dropping is right for work that will be redone from scratch on the next start anyway. Either way, never let the process exit with a pending TimerHandle and a half-applied state, and never leave a coalescer task running when the loop closes, or asyncio logs Task was destroyed but it is pending!. The general shutdown ordering is in shutting down async generators and executors cleanly.
Verify: trigger, then shut down immediately with flush=True; the work must have run exactly once before exit.
Verification¶
The trigger handling is correct when:
- Run count scales with bursts, not triggers — measure both under a synthetic burst.
- No two runs overlap for the coalescer, asserted inside the work function.
- The last trigger is always applied: the final run starts after the final trigger.
- A continuous stream still makes progress with the bounded debouncer.
Diagnostic Hook: export three counters — triggers received, runs started, and runs skipped or merged — plus a histogram of trigger-to-run delay. A trigger-to-run delay that keeps growing means the work is slower than the trigger rate and the coalescer is running back-to-back; that is your signal to make the work cheaper or to batch harder.
Pitfalls & edge cases¶
- Debouncing work that must reflect every event. If each trigger carries data (an audit record, a payment), merging them loses data; batch them in a queue instead, as in batching queue items by size and time.
- Clearing the dirty flag after the run. Triggers during the run are silently dropped.
- Per-key debouncers without cleanup. A dict of debouncers keyed by user id grows forever unless entries are removed after firing.
- Calling
trigger()from a thread.call_laterandcreate_taskare not thread-safe; route throughloop.call_soon_threadsafe(debouncer.trigger). - Exceptions in the debounced work. They land on a task nobody awaits; supervise it like any other background task.
Frequently Asked Questions¶
What is the difference between debouncing and coalescing?
Debouncing waits until triggers stop for a quiet period and then runs once. Coalescing runs as soon as possible but never concurrently, merging every trigger that arrives during a run into exactly one follow-up run. Debounce minimises runs; coalescing bounds staleness.
How do I debounce an async function in Python?
Keep a TimerHandle from loop.call_later; on every trigger cancel it and schedule a new one for the quiet period. When it fires, start the coroutine as a supervised task. Add a maximum wait so a continuous stream of triggers cannot postpone the work forever.
Why does my debounced function never run?
Triggers are arriving more often than the debounce delay, so the timer is always reset before it fires. Bound the wait from the first trigger in the burst so the work runs at least every max_wait seconds.
How do I stop the same coroutine running twice at once?
Use a coalescer: a dirty flag plus a single worker task that loops while the flag is set, clearing it before each run. New triggers either start the worker or mark it dirty for one more run.
Related¶
- Task Scheduling & Lifecycle — up to the topic overview.
- Watching files for changes in asyncio — the classic source of trigger bursts.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.