Skip to content

Leaks from Forgotten Callbacks and Timer Handles

loop.call_later, loop.call_at and future.add_done_callback all store a callable — usually a closure, a bound method or a functools.partial — and everything that callable references stays alive until the callback runs or is removed. Schedule a five-minute expiry for every request and forget to cancel it when the request finishes in 20 ms, and every request's data lives for five minutes. Measured on Python 3.14: 2,000 call_later(300, ...) handles whose callbacks referenced 10 KB each held 20.0 MiB, with 2,000 entries in the loop's timer heap; cancelling them released it to 0.1 MiB at once. 2,000 per-request done callbacks added to one long-lived "shutdown" future held 19.9 MiB until that future completed. Neither is a leak in the strict sense — everything is freed eventually — but at real request rates "eventually" is gigabytes. This guide finds these references and removes them at the right moment.

Prerequisites

1. Cancel timers when their purpose is fulfilled

A TimerHandle keeps its callback and arguments until it fires. If the event it guards against does not happen, cancel it:

async def handle(request) -> Response:
    loop = asyncio.get_running_loop()
    expiry = loop.call_later(300, expire_session, request.session)   # holds session for 5 min
    try:
        return await process(request)
    finally:
        expiry.cancel()                     # request done: release the session reference now

Measured: 2,000 un-cancelled 300-second timers held 20.0 MiB and 2,000 heap entries; after cancel(), 0.1 MiB. Cancelling clears the handle's callback and arguments immediately, so the referenced data is freed right away even though the heap entry is removed lazily. Most code should not call call_later for this at all: asyncio.timeout() creates and cancels its timer automatically on exit, and is the right tool for bounding an operation's duration.

Verify: len(loop._scheduled) (a private attribute, for diagnostics only) stays roughly constant under steady load rather than growing with the request count.

Memory held by 2,000 forgotten references to 10 KB each 4 horizontal bars comparing call_later(300), not cancelled with the others. Memory held by 2,000 forgotten references to 10 KB each call_later(300), not cancelled 20.0 MiB same handles after cancel() 0.1 MiB done callbacks on a long-lived future 19.9 MiB after the future completed 0.3 MiB Python 3.14; each callback closed over a 10 KB bytearray; tracemalloc totals. Callbacks keep their closures alive until they run or are removed.

2. Remove done callbacks from long-lived futures

Adding a per-request callback to a future that lives for the whole process — a shutdown signal, a "config reloaded" event, a connection-lost future — accumulates one closure per request:

shutdown: asyncio.Future = loop.create_future()


async def handle(request) -> Response:
    def on_shutdown(fut, request=request):
        request.abort()
    shutdown.add_done_callback(on_shutdown)              # one per request, never removed
    try:
        return await process(request)
    finally:
        shutdown.remove_done_callback(on_shutdown)       # remove it when the request ends

Measured: 2,000 such callbacks held 19.9 MiB until the future completed. remove_done_callback takes the same function object that was added, so keep a reference to it. Often a better design avoids per-request callbacks entirely: requests wait on asyncio.wait({request_task, shutdown_task}, return_when=FIRST_COMPLETED), or a single shutdown handler cancels a registry of tasks, as in draining in-flight requests before shutdown.

Verify: the number of callbacks on long-lived futures (len(fut._callbacks) in a debugger) does not grow with traffic.

3. Prefer weak or explicit ownership for callbacks on objects

Bound methods hold their instance. A callback registered on a long-lived emitter keeps every subscriber alive:

import weakref


class EventBus:
    def __init__(self) -> None:
        self._subscribers: weakref.WeakSet = weakref.WeakSet()

    def subscribe(self, subscriber) -> None:
        self._subscribers.add(subscriber)          # does not keep subscriber alive

    def publish(self, event) -> None:
        for subscriber in list(self._subscribers):
            subscriber.on_event(event)


class Session:
    def __init__(self, bus: EventBus) -> None:
        bus.subscribe(self)                        # freed when the session is dropped

Weak references let subscribers disappear without unsubscribing — but they also make lifetimes implicit. The explicit alternative is a subscription handle returned to the subscriber and closed in its finally or __aexit__, which keeps lifetimes visible in the code. Either way, a long-lived object holding strong references to short-lived ones is the pattern to look for.

Verify: after a burst of sessions ends, the bus holds no references to them (len(bus._subscribers) returns to baseline).

How a forgotten callback keeps request data alive A flow of 5 stages. How a forgotten callback keeps request data alive loop timer heap / long-lived future holds handle callback closure, partial, bound method captured references request, session their data bodies, buffers released when run, cancelled or removed The holder lives long; the captured data should not.

4. Find the holders with tracemalloc and gc

These references show up as memory attributed to the code that created the captured objects, persisting after the requests completed. Two checks narrow it to callbacks:

import gc
import tracemalloc


def callback_holders() -> dict[str, int]:
    loop = asyncio.get_running_loop()
    scheduled = [h for h in loop._scheduled if not h.cancelled()]        # diagnostics only
    futures = [o for o in gc.get_objects() if isinstance(o, asyncio.Future) and not o.done()]
    return {
        "scheduled_timers": len(scheduled),
        "max_callbacks_on_one_future": max((len(f._callbacks) for f in futures), default=0),
    }

A count of scheduled timers that tracks request volume, or a single pending future with thousands of callbacks, identifies the holder; a tracemalloc traceback of the retained objects (tracemalloc.start(25) and statistics("traceback")) shows which code scheduled them. Private attributes like _scheduled and _callbacks are for debugging only — use them in a diagnostics endpoint, not in production logic.

Verify: the diagnostics endpoint shows stable counts after a load test ends.

5. Make the release automatic with context managers

Scheduling something and cancelling it later is the same shape as acquiring and releasing a resource; give it the same structure:

from contextlib import contextmanager


@contextmanager
def scheduled(delay: float, callback, *args):
    handle = asyncio.get_running_loop().call_later(delay, callback, *args)
    try:
        yield handle
    finally:
        handle.cancel()


@contextmanager
def on_done(future: asyncio.Future, callback):
    future.add_done_callback(callback)
    try:
        yield
    finally:
        future.remove_done_callback(callback)


async def handle(request):
    with scheduled(300, expire_session, request.session), on_done(shutdown, request.abort_cb):
        return await process(request)

Wrapping registration in a context manager means the release cannot be forgotten on an error path or a cancellation. Code review then has one rule: no bare call_later or add_done_callback for per-request purposes outside these helpers or asyncio.timeout.

Verify: a grep for call_later( and add_done_callback( in request-handling code finds only the helpers.

What should replace this callback registration? A decision on What is the callback for with 4 outcomes. What should replace this callback registration? What is the callback for? bound an operation's time asyncio.timeout timer cancelled on exit expire something later context manager + cancel released on finish react to a long-lived future remove_done_callback / asyncio.wait no accumulation subscribe to an emitter WeakSet or handle freed with subscriber Every registration needs a matching release on every path.

Verification

Callbacks and timers do not hold memory when:

  • Per-request timers are cancelled when the request ends, or replaced by asyncio.timeout.
  • Callbacks on long-lived futures are removed when their request ends.
  • Long-lived emitters do not strongly hold short-lived subscribers.
  • Diagnostics show stable timer and callback counts under steady load.

Diagnostic Hook: export the number of scheduled, non-cancelled timers and the largest callback list on any pending future as debug metrics. Either one rising with traffic and falling only after long delays is a forgotten registration; the tracemalloc traceback of the memory it holds names the code that registered it.

Pitfalls & edge cases

  • Long call_later delays never cancelled. Measured: 20.0 MiB for 2,000 requests.
  • Per-request callbacks on process-lifetime futures. Measured: 19.9 MiB until the future completed.
  • Bound methods as callbacks. They keep their whole instance alive.
  • Relying on private attributes in production code. Use them only for diagnostics.

Frequently Asked Questions

Does loop.call_later keep objects alive?

Yes, the handle keeps its callback and arguments until it runs or is cancelled. In testing, 2,000 uncancelled 300-second timers held 20.0 MiB; cancelling them released it immediately.

Do I need to cancel asyncio timers when an operation finishes early?

Yes, if you scheduled them yourself with call_later or call_at. asyncio.timeout cancels its own timer automatically when the block exits.

Can add_done_callback cause a memory leak?

On a future that lives for a long time, yes: every added callback and what it references stays until the future completes. Remove callbacks with remove_done_callback when they are no longer needed.

How do I find which callbacks are holding memory in asyncio?

Count non-cancelled entries in the loop's scheduled timers and the callbacks on pending futures in a diagnostics endpoint, and use tracemalloc tracebacks to find the code that created the retained objects.