Here's a line of code that ships in a lot of Python services:
@app.post("/orders")
async def create_order(order: Order):
await save(order)
asyncio.create_task(send_webhook(order)) # fire and forget
return {"status": "created"}
The intent reads clearly: kick off the webhook, don't make the client wait for it. In development it works every time. In production it works almost every time, and "almost" here is the bad kind, because the failures don't error and don't retry. Some small percentage of webhooks simply never arrive, there's nothing in the error tracker, and the delivery code, when you test it, is flawless.
The webhook coroutine was garbage-collected partway through its own execution. That's not a metaphor for anything, the task object was reclaimed as garbage while suspended at an await, and the work just stopped existing.
The contract you didn't read
This behavior is documented, in the driest possible way, right on asyncio.create_task:
Important: Save a reference to the result of this function, to avoid a task disappearing mid-execution. The event loop only keeps weak references to tasks. A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done.
The event loop does not own your tasks. The registry behind asyncio.all_tasks() is a WeakSet, so nothing about being scheduled, or being half-finished, protects a task from collection, and the only party whose reference counts is you. Our endpoint calls create_task and drops the return value on the floor, which means from the garbage collector's perspective that task is eligible the moment nothing else happens to be pointing at it.
When the collector does take one, asyncio leaves a single trace: Task.__del__ logs "Task was destroyed but it is pending!" to stderr. In a real deployment that line lands somewhere in the log noise, minutes or hours from the request that spawned it, unconnected to any user-visible failure, and nobody links it to the missing webhooks because nothing about it says webhook.
Why it's intermittent
The part that makes this bug expensive to find is that dropping the reference doesn't reliably kill the task, it only makes the task killable, and whether it actually dies depends on what it's suspended on.
A suspended coroutine survives if anything reachable holds a chain of references to it, and some awaits build that chain for you by accident. Await asyncio.sleep and the loop's timer heap holds a handle, the handle leads to the future the sleep created, the future's completion callbacks lead back to your task, so the task is anchored by loop internals for the duration and survives its own abandonment. Await a socket read and the selector machinery does something similar.
But await something self-contained, an asyncio.Event only your task references, a queue nobody else holds, an internal condition, and you've built an island: your task references the event, the event's waiter future references your task, and no path from the outside world reaches either of them. Reference counting alone can't reclaim a cycle like that, but CPython's cycle collector exists for exactly this structure, and on some future collection pass it reaps the island whole, task and all, mid-await.
So the bug's reproduction recipe is an await that doesn't anchor you, a gen-2 collection pass at the wrong moment, and a reference you already dropped. Development runs are short and allocate little, so the deep collection passes rarely fire, and whatever you awaited may happen to anchor you anyway. Production runs are long and busy, and eventually the dice land wrong. None of which you're entitled to reason about, because which awaitables anchor and which don't is an implementation detail, and the documented contract is just the blunt sentence above: unreferenced tasks may die at any time.
Holding on properly
The standard idiom, straight from the docs, is a module-level set that owns in-flight tasks and prunes itself:
background_tasks: set[asyncio.Task] = set()
def fire_and_forget(coro):
task = asyncio.create_task(coro)
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return task
The set keeps every running task strongly referenced, and the done-callback removes each one on completion so the set doesn't grow forever. Eight lines, and the entire bug class is gone. If your codebase has bare create_task calls whose results nobody keeps, this wrapper is the cheapest correctness you'll buy this quarter, and it's also the natural place to hang the other thing fire-and-forget code always forgets, a callback that at least logs the task's exception if it died screaming.
Python 3.11's TaskGroup solves the same problem structurally, the group holds strong references to everything spawned inside it and the async with block refuses to exit until they're done. That changes semantics as well as safety, a webhook spawned in a request-scoped group delays the response until it's sent, so a TaskGroup is the right tool when the work belongs to the request, and the wrong one when the whole point was to answer before the work finishes.
For background work that's genuinely a service, the pattern that scales past both is a supervisor: one long-lived consumer task, created at startup and held on application state, fed by a queue and cancelled explicitly at shutdown. The request handler just enqueues, which can't be garbage-collected into silence, and delivery gains a place for retries and backpressure, which the fire-and-forget version was never going to grow anyway.
The design is right, which is the annoying part
It's fair to ask why the loop doesn't just hold every task strongly and make all of this impossible, and the answer is that the alternative failure mode is worse. A loop that owns every task turns every abandoned task into a permanent leak, a coroutine wedged on an event that will never fire can never be collected, because the runtime has no way to distinguish "abandoned by a bug, please reclaim" from "running on purpose, hands off." Weak references resolve the ambiguity by refusing to guess: anything someone still points at is wanted, anything nobody points at is garbage, and if you want background work to live, you prove it by holding it.
JavaScript instincts fail here in a familiar direction. A pending promise chain in JS is rooted by the runtime, the continuation sits in a job queue or hangs off the I/O that will resolve it, so fire-and-forget is structurally safe (your only obligation is handling the rejection), and engineers arriving from that world assume the runtime babysits async work, because theirs did. I wrote earlier about Python's await meaning "run this here" rather than "meanwhile", and this is the same philosophy surfacing one layer up: Python's async model keeps refusing to do things implicitly. Execution doesn't start implicitly, and lifetime isn't guaranteed implicitly, so there is no fire-and-forget in asyncio, there is only owned-by-someone, and every task in your codebase should be able to answer the question of who that someone is.
Top comments (0)