← back to journal

A cancelled asyncio timeout keeps your request alive

2026-10-13 · Kit Wickham

Wrap a fast request in asyncio.timeout(60) and the timeout almost never fires: the request finishes, the timer is cancelled, and that should be the end of it. In CPython's event loop it isn't. The cancelled timer stays in the loop's heap until a lazy cleanup removes it, and while it waits it still holds a copy of the request's contextvars context. Anything a context variable pointed to during the request stays alive with it. With 10,000 other timers on the loop, I measured 10,000 finished requests' contexts kept around at the peak. With Sentry's per-request scope in those contexts, that came to 132 MB instead of 1.6 MB. Clearing the context in Handle.cancel(), one line, brings it back to one context. uvloop doesn't have the problem.

Where the context goes

Every callback asyncio schedules is a Handle. When you don't pass a context, the handle takes contextvars.copy_context() of the code that scheduled it, so the callback later runs with the same context variables. asyncio.timeout() schedules its timer with loop.call_at(when, self._on_timeout) from inside your task, so the timer handle gets a copy of the task's context: request IDs, tracing spans, error-reporting scopes, whatever your framework keeps there. asyncio.wait_for() uses asyncio.timeout() since 3.12, and a cancelled asyncio.sleep() cancels its own timer handle the same way.

Here is Handle.cancel() in 3.14 and on main:

def cancel(self):
    if not self._cancelled:
        self._cancelled = True
        if self._loop.get_debug():
            self._repr = repr(self)
        self._callback = None
        self._args = None

It drops the callback and its arguments, but not _context. That would matter little if a cancelled timer left the loop right away, but it doesn't. The loop keeps timers in a binary heap, and removing an entry from the middle of a heap is awkward, so cancellation only marks the handle. The loop pops cancelled handles when they reach the front of the heap, and once there are more than 100 timers and more than half of them are cancelled, it rebuilds the heap without them. The cleanup came out of a 2014 issue about unbounded memory from cancelled timers; the 100 and the one-half are constants at the top of base_events.py. The context argument came four years later, in 3.7, and cancelled handles have held a context ever since.

So a cancelled timeout leaves quickly only if it's at the front of the heap. If any live timer has an earlier deadline (a slow request still in flight, a connection's idle timeout, a periodic task), every fast request's cancelled timer queues behind it until the cleanup. The cleanup runs when the cancelled timers outnumber the live ones, so with L live timers ahead, up to about L finished requests' contexts are held at once. Because of the 100-timer floor, even a single live timer ahead lets about 100 pile up. A cancelled timer also leaves by its own deadline at the latest, so a server handling fewer than L requests per timeout period holds fewer.

Counting them

Each simulated request sets a context variable to a small object I can track with a weak reference, runs async with asyncio.timeout(60): await asyncio.sleep(0), and ends. Separately, L timers sit on the loop with 30-second deadlines, ahead of every request's 60 seconds. After each request I count how many request objects are still alive. Python 3.14.8, garbage collector off so only plain references keep anything alive. "Fixed" is the same run with Handle.cancel() patched to also set self._context = None.

Live timers ahead (L)Requests kept, peakRequests kept, meanFixed, peak
199501
100100501
1,0001,0005001
10,00010,0005,8331
100,000100,00058,3331

The count climbs to L, the cleanup drops it, and it climbs again, so on average half to 60% of L finished requests are alive. The one that's always kept is the request just finished. With the same live timers placed after the requests' deadlines instead of before, nothing piles up: the cancelled timers are at the front and leave on the next loop iteration. Under uvloop 0.23.0 the count stayed at 1 for L = 1, 1,000 and 10,000: its timer handle closes its libuv timer when cancelled, so nothing waits for a cleanup.

What a kept context costs

That depends entirely on what your context variables point to. A request ID costs nothing worth measuring. When each request's context held a 32 KB object, peak traced memory at L = 10,000 was 320 MB against 3 MB fixed.

A more realistic payload is an error reporter's scope. The Sentry Python SDK keeps its per-request isolation scope (tags, user, breadcrumbs) in a context variable. I ran Sentry SDK 2.72.0 with no network: each request opened an isolation scope, set a tag and a user, and added breadcrumbs that looked like database queries. At L = 10,000:

Breadcrumbs per requestPeak, unpatchedPer kept requestPeak, fixed
037.6 MB3.9 KB1.5 MB
20132 MB13.5 KB1.6 MB

None of this is a leak in the strict sense: the cleanup does run, and memory stops growing at about L kept requests. But the bound scales with how many timers a busy server has, which is the same thing that makes the server big. A server with 10,000 open connections, each with an idle timeout, can carry 10,000 finished requests' worth of context.

The cleanup itself is a pause

The rebuild is a pass over the whole heap followed by heapify, done in one loop iteration. Normally a request in my loop took about 6 µs. The slowest one, the request during which the rebuild ran, took 1.5 ms at L = 10,000, 15 ms at 100,000 and 153 ms at a million, even with the fix. Unpatched it took 3.1, 36 and 366 ms, because the rebuild also frees all the kept contexts at once. The cost per timer is small and it's paid once per L cancellations, but it's paid all at once.

The fix

Setting self._context = None in Handle.cancel() is enough for the memory. A cancelled handle never runs, and the one place in the standard library that reads a handle's context afterwards, the loop's exception handler, already copes with None. Until something like that lands, the workarounds are uvloop, or keeping timeouts on long deadlines from sitting behind earlier timers, which is hard to arrange on a busy server. The rebuild pause would need a different structure, like uvloop's or a timer wheel, where cancelling removes the timer.

Update: CPython's asyncio test suite (2,675 tests) passes with the change, as it does without it. Versions measured: CPython 3.14.8 on Linux, uvloop 0.23.0, sentry-sdk 2.72.0. The Handle.cancel() code and the cleanup thresholds are the same on CPython's main branch today.