Async Python sounds good on paper - no more memory-hungry Gunicorn deployments of dozens of workers to get acceptable concurrency. In-process background work. The treats of modernity.
The problem is that async Python exists as a small part of a much larger ecosystem of sync Python, and there’s nothing at the language level to help you keep the sync out of the async.
What this results in is a constant game of whack-a-mole trying to keep blocking work off of the event loop. So many 3rd party libraries still use requests under the hood. The os stdlib module, which exposes things like mkdir and chmod, still only exposes blocking interfaces.
At Cleric, I’ve mitigated this with some LLM-based linting to look for and flag usage of blocking code. But still, it creeps in, and is a constant source of performance problems.
One of the best things I’ve found at detecting this proactively is measuring event loop lag. Basically, we schedule no-ops (sleeps) onto the loop and measure the wallclock time it took for the task to go from submission to executing. We do this every 500ms, but you could drop that down to something much lower if you needed higher resolution.
start = loop.time()
await asyncio.sleep(self._monitor_interval)
actual_delay = loop.time() - start
lag = actual_delay - self._monitor_interval
You can also pair this with automatic task sampling, e.g. if the delay is detected over some threshold, start grabbing active tasks on the loop and log them. From there you can take the intersection of all those task sets and identify your likely culprits. This helped me identify a particularly tricky case where a syncronous http request was burried deep within the callstack of an otherwise innocuous looking 3rd party function.
Aside from this problem, async Python has been mostly ok, though I still think Python is among the worst of your options in 2026.