An asyncio call times out and you handle the TimeoutError, but the background task keeps running and mutates shared state a few seconds later. What happened, and how do you make the timeout actually stop the work?
A timeout requests cancellation; it does not stop code. CancelledError is raised at the task next await point, so a task inside blocking code, shielded, in an executor thread, or swallowing the exception keeps running and commits its side effects late. Fix by re-raising CancelledError, keeping blocking calls off the loop, and using asyncio.timeout with a TaskGroup.
What the interviewer is scoring
- Whether the candidate knows cancellation is cooperative and is delivered at an await point, not immediately
- That they can name at least two ways a task survives cancellation - swallowing CancelledError, shield, executor threads, blocking sync code
- Does the answer mention that CancelledError derives from BaseException since Python 3.8, and why a bare except or except BaseException still traps it
- Whether they know wait_for awaits the cancellation, so a task ignoring it will hang the caller rather than return promptly
- That the fix includes making the side effect safe, not only making the cancellation faster
- Whether asyncio.timeout or TaskGroup is offered over hand-rolled create_task plus wait_for
Answer
Short answer
The timeout worked; the cancellation did not. asyncio.wait_for cancels the task it is waiting on, but cancellation in asyncio is cooperative: it schedules a CancelledError to be raised at the task's next await point. A task that is not currently at an await point, or that catches the exception and carries on, keeps running to completion — and commits its side effects long after the caller gave up.
Cancellation is a request, delivered at an await point
When wait_for expires it calls task.cancel(), which arranges for CancelledError to be thrown into the coroutine the next time it suspends. If the coroutine is sitting in await asyncio.sleep(30) it receives the error almost immediately. If it is halfway through a tight synchronous loop, a json.loads of a large payload, or a blocking driver call, there is no suspension point to deliver the exception to, and the loop is blocked anyway — nothing else runs, cancellation included, until that code returns.
This is the first thing to say in an interview, because it reframes the question. The bug is not that the timeout failed to fire. It is that the code being timed out never agreed to stop.
The four ways a task survives its own cancellation
It swallows the exception. This is the most common cause and it hides in ordinary-looking code:
async def sync_orders(session):
try:
for order in await fetch_pending(session):
await push_to_warehouse(order) # cancellation arrives here
except Exception: # <-- looks defensive, is a bug
log.exception("sync failed, continuing")
await mark_batch_complete(session) # runs anyway, after the caller left
Since Python 3.8 CancelledError inherits from BaseException rather than Exception, so except Exception no longer catches it — that change exists precisely because this bug was so common. But a bare except: or an explicit except BaseException: still traps it, and plenty of retry decorators and middleware are written that way.
It is shielded. asyncio.shield(coro) deliberately protects the inner task from cancellation propagating inward. The caller's await is cancelled, the inner work is not. Used knowingly this is correct — you want the payment to finish even though the HTTP client hung up. Used by copy-paste, it produces exactly the symptom in the question.
It is running in a thread. loop.run_in_executor(None, blocking_call) returns a future you can cancel, but cancelling it only detaches the future from the loop. There is no mechanism to interrupt a running thread in CPython, so the blocking call runs to completion and whatever it mutates gets mutated. The awaiting coroutine returns promptly and the work does not stop.
Nobody is holding a reference to it. asyncio.create_task returns a task the event loop only weakly references. If you discard the return value, the task can be garbage collected mid-flight, which produces the Task was destroyed but it is pending! warning — and in the meantime it is running outside any structure that would cancel it.
Why the caller sometimes hangs instead
There is a related symptom worth knowing because it looks like the opposite problem. wait_for does not just cancel and return; it awaits the cancellation, so it does not raise TimeoutError to your code until the inner task has actually finished unwinding. A task that catches CancelledError, runs a slow cleanup, and never re-raises will make wait_for block well past its own timeout. If someone reports that "the five second timeout took thirty seconds", this is usually why.
Fixing it
Re-raise cancellation after cleanup. If a task legitimately needs to catch CancelledError to close a file or release a lock, it must re-raise so the cancellation completes:
async def sync_orders(session):
try:
...
except asyncio.CancelledError:
await release_lease(session) # cleanup is fine
raise # <-- non-negotiable
except Exception:
log.exception("sync failed")
raise
Swallowing CancelledError without re-raising leaves the task in a state where the runtime believes it was cancelled and the code believes it was not.
Use structured concurrency. On Python 3.11 and later, asyncio.timeout and TaskGroup replace the hand-rolled pattern and make the cancellation scope explicit:
async with asyncio.timeout(5):
async with asyncio.TaskGroup() as tg:
tg.create_task(sync_orders(session))
tg.create_task(refresh_stock(session))
# leaving the block cancels every child task and waits for them
The TaskGroup keeps strong references, cancels siblings when one fails, and does not exit until every child has actually stopped. That removes the discarded-reference failure and the "caller moved on while work continued" failure in one move.
Keep blocking work off the loop, and make it interruptible where you can. A synchronous driver call cannot be cancelled, so the honest fix is a driver-level timeout — statement_timeout in Postgres, a socket timeout on the HTTP client — enforced by the thing actually doing the blocking rather than by asyncio.
Make the side effect safe. This is the part that survives every other mistake. If the task writes to shared state, guard the write so that a late-arriving completion cannot corrupt anything: check a cancellation flag or generation counter immediately before committing, or make the write idempotent and keyed so a stale one is discarded. Faster cancellation reduces the window; a guarded write closes it.
Proving it in a test
A test that asserts wait_for raised TimeoutError proves only that the caller stopped waiting, which is the thing that already worked. The assertion that matters is about the task: capture the task object, await a short settle period, and assert task.cancelled() is true, or assert that the side effect the task would have produced did not appear. Being able to articulate that distinction — the caller returning is not the work stopping — is usually the strongest signal in the whole answer.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Your task catches CancelledError to run cleanup. What must it do at the end of that handler, and why?
- The blocking work is a synchronous database driver call in run_in_executor. Can you cancel it at all?
- What does "Task was destroyed but it is pending!" mean when you see it in the logs?
- How would you prove in a test that the task actually stopped, rather than that the caller stopped waiting?
- When is asyncio.shield the correct tool rather than a bug?
Related questions
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumAlso on cancellation and timeouts7 min
- A rider requests a car and forty drivers are within range. Which one do you pick, and how long do you have to decide?hardAlso on timeouts and concurrency6 min
- A customer bought cover on your site last night and this morning the card payment failed. Are they on risk, and what does your system do next?hardAlso on cancellation6 min
- Two threads write to adjacent counters and throughput collapses. What is happening and how do you fix it?hardAlso on concurrency5 min