An async service handles ten requests per second and its CPU sits at eight percent. Nothing looks overloaded, latency is terrible, and every profile looks flat. The usual cause is a single synchronous call somewhere in a coroutine — a psycopg2 query, a requests.get, a bcrypt hash — that occupies the loop's thread and stops every other task from running while it completes.
Prerequisites
- Python 3.8+; the debug-mode behaviour described here is stable through 3.13.
- The loop model from debugging async code and event loops.
Solution
Debug mode times every callback and logs the slow ones with a traceback:
$ PYTHONASYNCIODEBUG=1 python -m myapp.server
WARNING:asyncio:Executing <Task pending name='Task-42'
coro=<handle_request() running at server.py:88>
created at server.py:61> took 0.412 seconds
0.412 seconds on a callback is the whole diagnosis: something inside handle_request ran synchronously for four hundred milliseconds, and during that time the loop did nothing else. The default threshold is 100 ms, which is far too coarse for a latency-sensitive service — lower it and the smaller stalls appear:
import asyncio
async def main() -> None:
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.02 # warn on anything over 20 ms
await serve()
asyncio.run(main(), debug=True)
Debug mode also reports coroutines that were never awaited, tasks destroyed while pending, and non-threadsafe calls from the wrong thread — the same switch covers all four, which is why it belongs on permanently in the test suite.
Once the offending call is identified, the fix is to move it off the loop thread:
import asyncio
import functools
async def hash_password(password: str) -> bytes:
loop = asyncio.get_running_loop()
# bcrypt is CPU-bound and synchronous: run it in the default thread pool.
return await loop.run_in_executor(None, functools.partial(bcrypt_hash, password))
async def fetch_rows(dsn: str, query: str) -> list:
# Better: use an async driver so there is no thread hop at all.
async with asyncpg.create_pool(dsn) as pool:
return await pool.fetch(query)
run_in_executor is the general escape hatch and costs a thread hop per call. An async-native library is better where one exists, because it removes the blocking entirely rather than relocating it.
Why this works
An event loop is a single thread running callbacks one at a time. Every await yields control back to the loop, which is how concurrency happens; a call that does not yield holds the thread until it returns. Debug mode wraps callback execution with a timer and logs any step exceeding the threshold, along with the coroutine's creation traceback — which is the part that makes the warning actionable, because the stall is reported with the source location where the task was created rather than only where it was running.
Measuring loop lag directly
Warnings tell you when a single callback was slow. A heartbeat tells you how the loop is behaving overall, under real load, and it costs almost nothing to run continuously:
import asyncio
import time
async def loop_lag_monitor(interval: float = 0.1, warn_ms: float = 50.0) -> None:
"""Sleep for `interval` and report how much longer it actually took."""
while True:
start = time.perf_counter()
await asyncio.sleep(interval)
lag_ms = (time.perf_counter() - start - interval) * 1000
if lag_ms > warn_ms:
print(f"event loop lag: {lag_ms:.1f} ms")
A healthy loop reports sub-millisecond lag. Sustained lag of tens of milliseconds means the loop is saturated; spikes correlated with a specific endpoint point straight at that handler. Because the measurement is a real task competing for the same loop, it reflects what user requests experience rather than what the process thinks it is doing.
Export the metric rather than printing it, and the same three lines become a production signal that distinguishes "the service is slow" from "the loop is blocked" — two problems with completely different fixes.
Auditing for blocking calls before they ship
Detection is better than diagnosis. Three habits catch blocking work at review time.
Keep a list of banned imports inside async modules. requests, psycopg2, time.sleep, subprocess.run and any synchronous SDK are all blocking; a lint rule or a simple grep in CI catches them before merge.
$ grep -rn "^import requests\|time\.sleep(\|psycopg2\.connect" src/myapp/async/ && exit 1
Turn the warning into a failure in tests. With debug mode on and the threshold low, a slow-callback warning during the test suite means a blocking call reached a coroutine — configure logging so that warning fails the test rather than scrolling past.
Name the executor boundary explicitly. A wrapper function called blocking_* or a module named sync_bridge makes the thread hop visible in review, so nobody has to guess whether a call is safe to await.
The one thing not to do is spread run_in_executor everywhere defensively. Each call costs a thread and a hop, and a service that wraps everything has re-implemented a thread pool server with extra steps — at which point the async model is providing cost without benefit.
Edge cases and failure modes
- Debug mode slows the loop measurably. Timing every callback and capturing creation tracebacks costs a few percent; keep it on in tests and bounded in production.
- The warning names the coroutine, not the blocking line. Combine it with a
py-spy dumpduring the stall, or bisect by addingawait asyncio.sleep(0)markers. - A thread pool does not help CPU-bound work. The GIL serialises it; use
ProcessPoolExecutoror move the work out of the process. run_in_executorpropagates exceptions, not cancellation. Cancelling the awaiting task does not stop the thread, which keeps running to completion.- Blocking during startup is invisible. Warnings only fire once the loop is running, so synchronous work in module import or in an
on_startuphook is not reported. - uvloop reports the same warnings but with slightly different thresholds and no Python-level callback wrapping in some paths; verify a suspected stall against the default loop before concluding it is loop-specific.
Choosing between an executor and an async library
Once a blocking call is found, there are two fixes and they are not equivalent.
run_in_executor keeps the synchronous library and moves it to a thread. It is a two-line change, it works with any library, and it costs a thread per concurrent call plus a scheduling hop. For a handful of calls per request — a password hash, an image resize, a synchronous SDK used once — it is the right answer, and the default ThreadPoolExecutor sized at min(32, cpu_count + 4) is usually adequate.
An async-native library removes the blocking entirely. asyncpg instead of psycopg2, httpx instead of requests, aiofiles instead of open for large reads. The change is larger — different API, different error types, different connection management — and the payoff is that concurrency stops being bounded by a thread pool.
The decision is mostly about call volume. A service making one blocking call per request at ten requests per second needs ten threads and an executor is fine. The same service at a thousand requests per second needs a thousand, which is not a thread pool but a problem — and there the migration is not optional.
import asyncio
from concurrent.futures import ThreadPoolExecutor
# An explicitly sized pool makes the bound visible rather than implicit.
_pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix="blocking")
async def legacy_lookup(key: str) -> dict:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_pool, sync_client.get, key)
Naming the pool's threads is worth the extra argument: py-spy and the task dump then show blocking-3 rather than Thread-7, and a saturated pool is visible at a glance instead of being inferred from latency.
Frequently Asked Questions
What counts as a blocking call in asyncio?
Anything that occupies the thread running the loop without awaiting: a synchronous database driver, requests, time.sleep, a large JSON parse, or a CPU-heavy loop. While it runs, no other task can make progress.
What does the slow callback warning actually measure?
The wall-clock duration of one callback or task step. Debug mode logs any step longer than loop.slow_callback_duration, which defaults to 100 milliseconds.
Should asyncio debug mode be enabled in production? Not continuously — it adds per-callback timing and coroutine creation tracebacks. Enable it in the test suite always, and in production only for a bounded investigation window. Does uvloop change any of this? The blocking model is identical — one thread, one callback at a time — and the slow-callback warning still fires. What differs is that uvloop's C implementation does not wrap every callback the same way, so a small number of internal paths are not timed. Diagnose a suspected stall against the default loop first, then confirm the fix under uvloop.
Related
- Debugging async code and event loops — task inspection and the rest of the loop toolkit.
- Debugging "Event loop is closed" RuntimeError — the lifetime failure that debug mode also reports.
- Reading flame graphs from py-spy output — seeing the blocking frame in a sampled profile.
- How to scope pytest fixtures for async tests — keeping the test loop healthy while you investigate.
← Back to Debugging Async Code and Event Loops