Skip to content

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

How a task disappears Four lifelines tracing a task from creation to garbage collection. How a task disappears your code create_task event loop GC create_task(coro()) scheduled, runs to first await holds only a WEAK reference collects the task object Task was destroyed but it is pending The loop never owned it; when your reference went, so did the work.

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.

The three lines that fix it A pipeline of the strong-reference background-task pattern. The three lines that fix it create_task returns the task add to a set a strong reference add_done_callback set.discard task completes reference dropped The discard callback is what stops the fix becoming a memory leak.

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.

Detached work versus a TaskGroup Two columns contrasting detached background tasks with scoped task groups. Detached work versus a TaskGroup strong-reference set outlives the caller For fire-and-forget work You own naming and error logging Cap it, or it accumulates Cancel it at shutdown asyncio.TaskGroup scoped to a block Holds strong references for you Awaits every child at exit Exceptions become a group Cannot outlive the block Use the group where the scope allows; the set only for genuinely detached work.

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() raises CancelledError if the task was cancelled; check task.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_future and 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.