Skip to content

Using loop.call_later and Timer Handles

Coroutine code rarely needs raw timers — asyncio.sleep() and asyncio.timeout() cover most cases. But protocol implementations, connection managers and event-driven components live in callback land, where there is no coroutine to suspend: an idle connection must be closed if no bytes arrive for 60 seconds, a burst of file-change events should trigger one rebuild rather than two hundred, a lease must be renewed shortly before it expires. Those are jobs for loop.call_later() and loop.call_at(), which schedule a plain callback on the loop's timer heap and return a TimerHandle that can be cancelled. Used carelessly they leak callbacks that fire after the object they reference is gone, or pile cancelled timers into the scheduler's heap. This guide covers the handle lifecycle, an idle timeout that resets on activity, a debouncer, the measured cost of cancelled timers, and where the higher-level APIs are the better tool.

Prerequisites

From call_later to a running callback 4 stacked layers from call_later / call_at to Callback. From call_later to a running callback call_later / call_at TimerHandle when = loop.time() + delay Timer heap ordered by when cancelled stay until cleaned Loop iteration due timers to ready queue cancelled ones dropped Callback synchronous, loop thread errors to exception handler A handle is only a reservation on the heap; cancelling marks it, cleanup comes later.

1. Schedule, inspect and cancel a timer

call_later(delay, callback, *args) schedules at loop.time() + delay; call_at(when, ...) takes an absolute loop time. Both return a TimerHandle with when(), cancel() and cancelled(). The callback is synchronous, runs on the loop thread, and any exception it raises goes to the loop's exception handler rather than to whoever scheduled it.

import asyncio


async def main() -> None:
    loop = asyncio.get_running_loop()
    fired: list[str] = []

    handle = loop.call_later(0.05, fired.append, "renew-lease")
    print(type(handle).__name__, "due in", round(handle.when() - loop.time(), 2), "s")

    at_handle = loop.call_at(loop.time() + 10, fired.append, "never")
    at_handle.cancel()                                   # safe to call more than once
    print("cancelled:", at_handle.cancelled())

    await asyncio.sleep(0.1)
    print("fired:", fired)                               # ['renew-lease']


asyncio.run(main())

Cancelling a handle that already ran or was already cancelled is a no-op, so cleanup code can cancel unconditionally. Pass arguments positionally rather than wrapping them in a lambda where possible; the handle keeps references to the callback and its arguments until it runs or is removed from the heap, and a closure over self keeps the whole object alive.

Verify: the handle reports due in 0.05 s, the cancelled handle reports True, and only renew-lease fires.

2. Build an idle timeout that resets on activity

A connection that receives no data for a period should be closed. The pattern: schedule a timer when the connection opens, cancel and reschedule it whenever data arrives, and cancel it when the connection closes so the callback never fires on a dead connection.

import asyncio


class IdleTimeoutProtocol(asyncio.Protocol):
    def __init__(self, idle_timeout: float, events: list[str]) -> None:
        self.idle_timeout = idle_timeout
        self.events = events
        self.transport: asyncio.Transport | None = None
        self._idle: asyncio.TimerHandle | None = None

    def connection_made(self, transport: asyncio.Transport) -> None:
        self.transport = transport
        self._arm()

    def data_received(self, data: bytes) -> None:
        self.events.append(f"data {data!r}")
        self._arm()                                      # activity resets the deadline

    def connection_lost(self, exc: Exception | None) -> None:
        if self._idle is not None:
            self._idle.cancel()                          # never fire on a closed connection
        self.events.append("closed")

    def _arm(self) -> None:
        if self._idle is not None:
            self._idle.cancel()
        loop = asyncio.get_running_loop()
        self._idle = loop.call_later(self.idle_timeout, self._on_idle)

    def _on_idle(self) -> None:
        self.events.append("idle timeout")
        self.transport.close()


async def main() -> None:
    loop = asyncio.get_running_loop()
    events: list[str] = []
    server = await loop.create_server(lambda: IdleTimeoutProtocol(0.1, events), "127.0.0.1", 0)
    port = server.sockets[0].getsockname()[1]
    reader, writer = await asyncio.open_connection("127.0.0.1", port)
    for chunk in (b"a", b"b"):
        writer.write(chunk)
        await writer.drain()
        await asyncio.sleep(0.06)                        # under the timeout: keeps it alive
    await asyncio.sleep(0.2)                             # silence: server closes the connection
    print(events)                                        # data a, data b, idle timeout, closed
    writer.close()
    server.close()
    await server.wait_closed()


asyncio.run(main())

Two writes 60 ms apart keep the 100 ms idle timer from firing; the silence afterwards lets it fire and close the transport, and connection_lost cancels the stale handle. Streams-based code can get the same behaviour with asyncio.timeout() around each read — the coroutine version in setting connect, read and total timeouts in async HTTP clients.

Verify: the events list reads data b'a', data b'b', idle timeout, closed, with no events after closed.

3. Debounce bursts of events into one action

File watchers, configuration pushes and UI-like event sources emit bursts. Debouncing runs the action once, a quiet period after the last event in a burst, by rescheduling a single timer on every event.

import asyncio


class Debouncer:
    def __init__(self, delay: float, action) -> None:
        self._delay = delay
        self._action = action
        self._handle: asyncio.TimerHandle | None = None
        self._pending: list[str] = []

    def trigger(self, item: str) -> None:
        self._pending.append(item)
        if self._handle is not None:
            self._handle.cancel()
        self._handle = asyncio.get_running_loop().call_later(self._delay, self._fire)

    def _fire(self) -> None:
        batch, self._pending = self._pending, []
        self._handle = None
        self._action(batch)

    def close(self) -> None:
        if self._handle is not None:
            self._handle.cancel()


async def main() -> None:
    rebuilds: list[list[str]] = []
    debounce = Debouncer(0.05, rebuilds.append)
    for i in range(200):                                  # a burst of change events
        debounce.trigger(f"file-{i}.py")
        if i % 50 == 0:
            await asyncio.sleep(0)
    await asyncio.sleep(0.1)
    debounce.trigger("late.py")                           # a separate, later event
    await asyncio.sleep(0.1)
    debounce.close()
    print([len(batch) for batch in rebuilds])             # [200, 1]


asyncio.run(main())

Two hundred events produced one rebuild with the whole batch, and a later event produced its own. If the action is a coroutine, have _fire create a task — and keep a reference to it — rather than calling it directly, since timer callbacks cannot await. Watching real files is covered in watching files for changes in asyncio.

Verify: the batch sizes print as [200, 1].

Debouncing a burst of events 3 lanes over time. Debouncing a burst of events events timer quiet period quiet period action run 200 run 1 time → Every event moves the deadline; only silence lets it fire.

4. Know what cancelled timers cost the scheduler

Cancelling a handle does not remove it from the loop's heap immediately. The loop removes cancelled timers lazily: at each iteration it pops cancelled handles from the front of the heap, and when more than half of a heap with over a hundred entries is cancelled, it rebuilds the heap. Tight reschedule loops that never yield can therefore grow the heap temporarily.

import asyncio
import time


async def main() -> None:
    loop = asyncio.get_running_loop()                     # loop._scheduled is internal: demo only

    handle = loop.call_later(30, lambda: None)
    started = time.perf_counter()
    for _ in range(100_000):                              # reset an idle timer 100k times
        handle.cancel()
        handle = loop.call_later(30, lambda: None)
    print(f"100k resets: {time.perf_counter() - started:.2f}s, heap size {len(loop._scheduled)}")

    await asyncio.sleep(0)                                # one loop iteration rebuilds the heap
    print("heap size after one iteration:", len(loop._scheduled))

    handle.cancel()


asyncio.run(main())

On Python 3.14, 100,000 cancel-and-reschedule cycles took about 0.1 seconds on an idle machine and left the heap holding over 100,000 entries until the next loop iteration, when the rebuild shrank it to one. In a real protocol each reset happens in a separate data_received call, so the heap is cleaned continuously; the cost that remains is on the order of a microsecond per reset, plus a heap entry that lingers until cleanup. For very high message rates, avoid resetting on every message: record the time of last activity in data_received, and let a single periodic check compare it with the deadline.

Verify: the first line shows a heap far larger than the one live timer, and the second shows it cleaned up after one iteration.

5. Prefer the coroutine timeout APIs in coroutine code

Timers belong to callback-based code. Inside coroutines, the higher-level APIs handle cancellation, cleanup and exception propagation for you — including resettable deadlines, via asyncio.Timeout.reschedule().

import asyncio


async def read_with_idle_timeout(reader: asyncio.StreamReader, idle: float) -> list[bytes]:
    loop = asyncio.get_running_loop()
    chunks: list[bytes] = []
    try:
        async with asyncio.timeout(idle) as deadline:
            while chunk := await reader.read(1024):
                chunks.append(chunk)
                deadline.reschedule(loop.time() + idle)   # activity pushes the deadline out
    except TimeoutError:
        chunks.append(b"<idle>")
    return chunks


async def main() -> None:
    reader = asyncio.StreamReader()

    async def feed() -> None:
        for piece in (b"x", b"y"):
            await asyncio.sleep(0.03)
            reader.feed_data(piece)

    feeder = asyncio.create_task(feed())
    print(await read_with_idle_timeout(reader, idle=0.1))  # [b'x', b'y', b'<idle>']
    await feeder


asyncio.run(main())

asyncio.timeout() raises TimeoutError in the coroutine at the pending await, unwinds finally blocks, and cannot fire after the block exits — the guarantees that step 2 had to build by hand. Deadline rescheduling is covered in depth in rescheduling asyncio.timeout deadlines dynamically.

Verify: the function returns both chunks followed by <idle>, about 100 ms after the last byte.

Timer tools by context A grid of 4 rows by 1 columns. Timer tools by context situation tool wait inside a coroutine asyncio.sleep / asyncio.timeout resettable coroutine deadline Timeout.reschedule() protocol or callback code loop.call_later + handle thousands of resets per second last-activity time + periodic check Raw handles are for code that has no coroutine to suspend.

Verification

Timer usage is sound when:

  • Every handle has an owner that cancels it when the object it serves closes.
  • Callbacks never fire on dead state: close paths cancel timers, and callbacks check state before acting.
  • Resets are cheap at your message rate: either one cancel-and-reschedule per event at modest rates, or a last-activity timestamp with a periodic check at high rates.
  • Bursts are debounced: repeated events trigger one action after a quiet period.
  • Coroutine code uses coroutine APIs: asyncio.timeout() with reschedule(), not raw handles.

Pitfalls & edge cases

  • Lambdas capturing self. A pending handle keeps its callback alive, so a closure over a connection object delays its garbage collection until the timer runs or is cleaned from the heap.
  • Exceptions in callbacks. They go to the loop exception handler, not to the scheduling code. Wrap callbacks that can fail, or install a structured handler as in installing a custom exception handler on the event loop.
  • Calling from another thread. call_later is not thread-safe. From a foreign thread, use loop.call_soon_threadsafe(loop.call_later, delay, callback).
  • Long delays across clock changes. Loop time is monotonic, so wall-clock adjustments do not affect timers — but a laptop sleeping or a VM pausing does delay them. Recheck deadlines on wake-up for anything security-sensitive.
  • Starting coroutines from callbacks without references. asyncio.create_task() in a timer callback needs a strong reference, or the task can be garbage-collected mid-flight.

Frequently Asked Questions

What does loop.call_later return in asyncio?

It returns an asyncio.TimerHandle representing the scheduled callback. The handle exposes when, which gives the absolute loop time it will run, cancel, which prevents it from running, and cancelled. Cancelling is safe to repeat and has no effect if the callback already ran.

How do I reset a timer in asyncio when activity happens?

In callback code, cancel the existing TimerHandle and schedule a new one with call_later each time activity occurs, and cancel it when the connection or object closes. In coroutine code, use asyncio.timeout and call reschedule on its context manager with a new deadline whenever activity occurs.

Are cancelled asyncio timers removed from the event loop immediately?

No. Cancelled handles stay in the loop's timer heap until the loop cleans them up: cancelled handles at the front are removed each iteration, and the heap is rebuilt when more than half of a sufficiently large heap is cancelled. Tight loops that reschedule without yielding can temporarily grow the heap.

Should I use call_later or asyncio.sleep?

Use asyncio.sleep, asyncio.timeout or asyncio.wait_for inside coroutines, because they integrate with cancellation and exception propagation. Use call_later and call_at in callback-based code such as protocols, where there is no coroutine to suspend, and always keep and cancel the returned handle.