Blog
Concurrency2026-W205 min readby delve

Fire-and-Forget asyncio.create_task Is a Reliability Trap

An unawaited `create_task` looks identical to a properly-handled background task — until the day it raises an exception, your endpoint returns 200, and nobody knows the notification never went out. Failures must surface somewhere, or they don't exist.

A paper airplane mid-flight against a slate-grey wall, sharp focus on the folded nose, no people, no hands, dramatic single-source side light, editorial.

The problem

You have an async route that needs to push a notification after saving to the database. The notification is not in the critical path — if it fails, the HTTP response can still succeed. So you reach for asyncio.create_task:

@app.post("/webhook") async def webhook(request: Request): # ... validate, parse, save ... asyncio.create_task(notifier.send(recipient, payload)) return {"ok": True}

The route returns immediately, the task runs "in the background." This works during development. In production you ship it, and then one day the notifier raises an exception inside send. Your logs are clean. Your endpoint returns 200. The recipient never receives the notification, and nobody knows.

asyncio.create_task wraps a coroutine in a Task object and schedules it on the current event loop. If the task raises an unhandled exception, Python emits a Task exception was never retrieved warning to sys.stderrif the garbage collector runs before the task is reclaimed. That warning is easily missed, especially when it arrives hours after the request that caused it. The request itself returned 200. The caller has no way to know anything failed.

The approach

Two patterns worth knowing:

Await the coroutine directly if the caller should know about failures. This is almost always the right choice when the notification is part of the expected success path:

try: await notifier.send(recipient, payload) except Exception as e: logger.warning(f"delivery to {recipient!r} failed: {e}") return {"ok": False, "reason": "delivery_failed"}

The route takes slightly longer. The failure is surfaced. The caller gets a non-200 if the notification fails, which is accurate.

Store a reference if true background execution is required. If the work genuinely must not block the response, keep a reference to the task so exceptions are not silently swallowed:

_background_tasks: set[asyncio.Task] = set() def _create_background_task(coro): task = asyncio.create_task(coro) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) return task

add_done_callback fires when the task completes, whether successfully or with an exception. If you attach a callback that logs the exception, nothing is silently swallowed. The set keeps a hard reference so the task is not garbage-collected before it finishes.

What I learned

The silencing property of fire-and-forget tasks is not obvious from the code. It looks almost identical to a properly-handled background task, but the difference is whether failures surface anywhere. A 500ms timeout on send with the first pattern would have surfaced the problem immediately in the test environment. With create_task, the failure moved entirely out of observable state.

The rule I now use: if a task's failure would require me to manually check logs to know it happened, the task should be awaited with an explicit error path. True fire-and-forget — where the outcome genuinely does not matter — is rarer than it feels in the moment.