Preventing Task Garbage Collection with Strong References¶
A fire-and-forget task disappears occasionally. Not always — that would be easy — but under load, on some deployments, a percentage of background writes never happen and there is nothing in the logs beyond an occasional Task was destroyed but it is pending!. The cause is documented but easy to miss: the event loop keeps only a weak reference to a task while it is suspended, so if your code holds no reference either, the garbage collector is free to destroy a perfectly healthy task mid-flight.
The consequences are exactly as bad as they sound — a partially executed coroutine, its finally blocks skipped, its work silently dropped. The fix is three lines, and the surrounding discipline (naming, error handling, cleanup) is what turns those three lines into a background-task pattern you can rely on.
Prerequisites¶
- Python 3.11+; standard library only.
- The task scheduling and lifecycle overview, and structured concurrency with asyncio.TaskGroup for the alternative in step 4.
1. Reproduce the disappearance¶
The failure needs GC pressure to show, which is why it appears in production and not in tests.
import asyncio
import gc
async def background_write(n: int) -> None:
await asyncio.sleep(0.05)
print(f"wrote {n}") # not always reached
async def main() -> None:
for n in range(1000):
asyncio.create_task(background_write(n)) # reference dropped immediately
gc.collect() # simulate normal GC pressure
await asyncio.sleep(1.0)
asyncio.run(main()) # prints fewer than 1000 lines, and warns on destroy
The loop schedules the task, but once it suspends at the await, the only strong reference is whatever you kept — and here that is nothing. gc.collect() makes it deterministic; in production, ordinary collection cycles do the same thing unpredictably. Verify: count the printed lines; a run that prints fewer than 1000 has just lost real work.
2. Keep a strong reference until the task completes¶
The canonical fix: a module-level set that holds each task and discards it on completion.
import asyncio
_background: set[asyncio.Task] = set()
def spawn(coro, *, name: str | None = None) -> asyncio.Task:
task = asyncio.create_task(coro, name=name)
_background.add(task) # strong reference: GC cannot collect it
task.add_done_callback(_background.discard) # and it is removed when finished
return task
add_done_callback(_background.discard) is the important half: without it the set grows forever and you have replaced a lost-task bug with a memory leak. The callback runs after the task completes — successfully, with an exception, or cancelled — so the set's size tracks live background work and doubles as a useful gauge. Verify: run the step 1 loop through spawn(); all 1000 lines print, and len(_background) returns to zero afterwards.
3. Add error handling while you are there¶
A task nobody awaits also has nobody to notice its exception.
import asyncio
import logging
log = logging.getLogger("background")
_background: set[asyncio.Task] = set()
def _done(task: asyncio.Task) -> None:
_background.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc is not None:
log.error("background task %s failed", task.get_name(), exc_info=exc)
def spawn(coro, *, name: str | None = None) -> asyncio.Task:
task = asyncio.create_task(coro, name=name or getattr(coro, "__qualname__", None))
_background.add(task)
task.add_done_callback(_done)
return task
Retrieving the exception in the callback both logs it immediately and prevents the "exception was never retrieved" warning at collection time — a warning that arrives long after the failure and is routinely filtered out of log pipelines. Naming the task from the coroutine's qualified name makes the log line and any task dump immediately readable. Verify: a failing background task logs an error the moment it fails, not at the next collection.
4. Prefer a TaskGroup where the scope allows¶
If the work must finish before a request or a service exits, structured concurrency removes the problem entirely.
import asyncio
async def handle_request(payload) -> dict:
async with asyncio.TaskGroup() as tg: # holds strong references
primary = tg.create_task(store(payload), name="store")
tg.create_task(audit(payload), name="audit")
return primary.result() # both finished here
A TaskGroup holds strong references to its children and awaits them all at exit, so no task can be collected and no exception can be lost. Its limitation is precisely its strength: it will not let the block finish early, so it cannot express "start this and let it outlive the request". Use the group for scoped concurrency and the strong-reference set only for genuinely detached work — a metrics flush, a cache warm, a fire-and-forget notification. Verify: the handler returns only after both children complete, and an exception in either surfaces as an ExceptionGroup.
5. Bound detached work so the set cannot grow without limit¶
A strong-reference set makes tasks survive, which also means an unbounded spawn rate now leaks memory instead of losing work.
import asyncio
_background: set[asyncio.Task] = set()
_slots = asyncio.Semaphore(500) # cap on concurrent detached tasks
async def _guarded(coro) -> None:
async with _slots:
await coro
def spawn_bounded(coro, *, name: str | None = None) -> asyncio.Task | None:
if _slots.locked():
DROPPED.inc() # shed rather than accumulate
return None
return spawn(_guarded(coro), name=name)
Detached work is exactly the category that grows silently: a per-request notification that is slower than the request rate accumulates forever. Cap it, count what you shed, and export len(_background) as a gauge — a steadily climbing value is the signal that the background work is slower than its source. Verify: with the spawn rate above the completion rate, the gauge plateaus at the cap and the shed counter rises, rather than the process running out of memory.
Verification¶
- No work is lost. Under GC pressure, every spawned task runs to completion; the destroyed-while-pending warning never appears.
- The set drains.
len(_background)returns to zero when background work is idle. - Failures are visible. A raising background task logs an error immediately, with its name.
Pitfalls and edge cases¶
asyncio.create_task(...)as a bare statement. The task is unreferenced from that instant; linters flag it (RUF006) for exactly this reason.- A set with no
discard. Fixes collection, introduces a leak; the done-callback is not optional. - Storing tasks in a list. Removal is O(n) and duplicates accumulate; use a set.
- Assuming the loop keeps tasks alive. It holds weak references while a task is suspended — the whole cause of this bug.
- Ignoring cancellation in the callback.
task.exception()raisesCancelledErrorif the task was cancelled; checktask.cancelled()first. - Detached work at shutdown. Tasks in the set are still running when the loop stops; cancel and await them in the shutdown sequence.
- Using
ensure_futureand expecting different behaviour. It creates the same kind of task, with the same weak-reference exposure.
Frequently Asked Questions¶
Why do my asyncio background tasks disappear?
Because the event loop keeps only a weak reference to a suspended task. If your code drops its reference — the classic bare asyncio.create_task(coro()) statement — the garbage collector may destroy the task mid-flight, skipping its remaining code and its finally blocks. Keep the task in a module-level set and remove it in a done callback.
What is the correct fire-and-forget pattern in asyncio?
Create the task, add it to a set that lives as long as the application, and attach a done callback that discards it from the set and logs any exception. That combination prevents collection, prevents the set from growing forever, and makes failures visible immediately instead of at garbage-collection time.
Does a TaskGroup solve the garbage collection problem?
Yes, within its scope. A TaskGroup holds strong references to its children and awaits them at block exit, so nothing can be collected and no exception is lost. It cannot express work that should outlive the enclosing block, which is where the strong-reference set is still needed.
Can holding task references cause a memory leak?
Yes, if you never remove them. The done callback that discards each task from the set is what keeps it bounded. Beyond that, cap the number of concurrent detached tasks with a semaphore and shed when the cap is reached, since detached work that is slower than its spawn rate will otherwise accumulate indefinitely.
Related¶
- Task Scheduling & Lifecycle — the parent overview: how tasks are scheduled and what states they pass through.
- Structured concurrency with asyncio.TaskGroup — the scoped alternative to detached tasks.
- Inspecting pending tasks with asyncio.all_tasks — auditing what background work is actually alive.