The Blocking Call That Freezes the Loop
One synchronous call in a coroutine stops every other coroutine dead. It is the most common async bug, and the way it hides is worse than the way it looks.
One synchronous call in a coroutine stops every other coroutine dead. It is the most common async bug, and the way it hides is worse than the way it looks.
This explorer needs JavaScript: every time, depth and lag on it is computed in the page rather than downloaded as an image.
Nothing enforces cooperation. The loop cannot preempt a coroutine mid-call, so a blocking line does not slow the loop down, it *pauses it entirely* until the line returns.
Async Python runs on cooperative multitasking. The event loop runs on a single thread and can only switch coroutines when the running one hits an await .
The danger is that nothing warns you. A call like requests.get(url) inside a coroutine compiles fine, runs fine on its own, and only breaks once you have other coroutines and tasks that need to run at the same time. By then the bug is in production and expressing itself as mysterious latency spikes rather than a clear error.
This page shows exactly how a blocking call freezes the loop, how to spot it, and how to fix it by pushing the work to a thread with run_in_executor.
Here a heartbeat prints a tick every 0.1 seconds — the kind of steady progress a healthy loop makes — while a second coroutine does a blocking time.sleep(0.3) instead of an async one.
These editors run inside a browser event loop that is already going, so the examples finish with await main(). In a standalone .py script — run with python file.py — you write asyncio.run(main()) instead, which starts a loop, runs the coroutine, and closes it. The two are the same program with different entry points.
tick
blocker done
tick
tick
tick
tickThe first tick prints, the heartbeat hits its await and steps aside, and then blocker runs time.sleep(0.3). For those 0.3 seconds the thread is gone: the heartbeat's timer expires in real time but the loop cannot act on it, so no tick appears. Only when time.sleep returns does the loop catch up, firing the four backed-up ticks in a burst. The gap between the first tick and the rest is the loop frozen solid.
The insidious part is that time.sleep is standing in for anything synchronous that takes time: requests.get, a blocking database driver, reading a large file, json.loads on a huge string, a tight numeric loop. Each looks like ordinary code and each freezes the loop for its full duration.
The blocking call has to stop holding the thread. The direct fix — and the one this editor can show — is to use the async-native version of whatever you were doing. Here that means await asyncio.sleep in place of time.sleep; in real code it means an async HTTP client (httpx, aiohttp) instead of requests, or an async database driver instead of a blocking one.
tick
tick
tick
blocker done
tick
tickNow the ticks keep coming. await asyncio.sleep suspends only blocker and hands the thread back to the loop, so the heartbeat runs on schedule and blocker done lands in the middle of the ticks rather than after a frozen gap. The wait still took 0.3 seconds; it just no longer took the whole program with it. Every async-native library does the same thing — its await is a point where the loop can run something else.
Sometimes the blocking call has no async form: a legacy library with only a synchronous API, or a lump of pure computation. Then you move it off the loop onto a worker thread and await that:
async def blocker():
await asyncio.to_thread(time.sleep, 0.3) # run the blocking call on a thread
print("blocker done")asyncio.to_thread is the escape hatch for a blocking function you cannot rewrite, and loop.run_in_executor is its older sibling. Two caveats. It needs real threads, so it does nothing in a sandbox that has none — the browser interpreter running these editors is one such sandbox, which is why this snippet is shown rather than run. And for CPU-bound work a thread does not help even where threads exist, because the work still contends for the one interpreter; that belongs in a separate process.
This bug rarely shows up in development and reliably shows up under load, for a structural reason. With one user, one blocking call just makes that one request a little slow, which looks like the network. With a hundred concurrent users, one blocking call in the request path freezes *all* of them for its duration, because they share the single thread — so the symptom is not "one slow endpoint" but "the whole service periodically stops responding", which points nowhere obvious.
It is also invisible to a glance at the code. A blocking call has the same shape as an async one; only knowing the library tells you which it is. requests.get(url) and await client.get(url) look almost identical and behave completely differently inside a loop.
The loop can tell you. Running with debug mode on makes asyncio warn about any callback that holds the thread too long:
import asyncio
async def main():
...
asyncio.run(main(), debug=True) # warns: "Executing ... took N seconds"With debug on, a blocking call produces a "slow callback" warning naming the place it happened, which turns an invisible freeze into a log line. The habits that prevent it in the first place: use async-native libraries in coroutines (httpx or aiohttp over requests, an async database driver over a blocking one), and wrap anything unavoidably synchronous in to_thread.
"The loop froze" is a claim you can put a number on, and doing so is how this bug gets found in a system too large to eyeball. The measurement is called loop lag: ask the loop to wake you in 50 ms, see how long it actually took, and the difference is how long something else held the thread.
asked for 0.050s; worst actual was 0.270s
peak loop lag: 220 msA 50 ms sleep took 270. The extra 220 ms is the blocking call: the monitor's timer came due on schedule and the loop could not act on it, because time.sleep was holding the only thread. Nothing here inspects the blocking code — the freeze is measured entirely from the outside, by a coroutine that only knows how long it waited.
That is what makes the technique useful in production. A coroutine like monitor, sampling continuously and reporting its worst lag, needs no knowledge of what the rest of the program does and will catch any blocking call anywhere in it. Healthy loops sit at a lag of a millisecond or two; sustained tens of milliseconds means something is holding the thread, and the shape of the spikes usually identifies which request path it is on.
The built-in version of the same idea is debug mode, which is cheaper to switch on and coarser:
import asyncio
async def main():
...
asyncio.run(main(), debug=True) # warns: "Executing ... took N seconds"With debug on, asyncio logs a warning naming any callback that ran longer than loop.slow_callback_duration (0.1 s by default, and adjustable). That turns an invisible freeze into a log line with a source location in it, which is usually enough to find the call. Debug mode also warns about coroutines that were never awaited and tasks that were destroyed while pending, so it is worth switching on in development regardless of this bug.
The awkward part of this bug is that blocking calls do not look distinctive. Here is the list that accounts for almost every real case, with what to do about each:
| Blocking call | Fix |
|---|---|
requests.get, urllib | httpx or aiohttp |
time.sleep | await asyncio.sleep |
psycopg2, sqlite3, most DB drivers | an async driver: asyncpg, aiosqlite |
open().read() on a large file | aiofiles, or to_thread |
json.loads on a huge payload | to_thread, or parse less |
| A regex over a very long string | to_thread |
subprocess.run | asyncio.create_subprocess_exec |
| A tight numeric loop, model inference | a process pool, not a thread |
Three fixes, and the choice between them is not a preference. An async-native library is always the right answer when one exists, because it makes the wait a real await. to_thread is for I/O-bound calls with no async version, and it works because the blocking C call releases the interpreter lock while it waits. For CPU-bound work neither helps: the computation needs the interpreter the whole time, so a thread contends for the same lock and only a separate process genuinely moves the work off the loop.
The rule that follows is worth keeping as a habit rather than a debugging step. Inside a coroutine, every call either awaits or returns fast. Anything that does neither needs one of the three fixes above before it goes in.
A synchronous HTTP or database call in a coroutine. The classic. It freezes the loop for the full round trip. Use the async client; or, if there is none, to_thread where real threads exist.
time.sleep instead of asyncio.sleep. The single most common beginner version, and the one to recognise on sight.
CPU-bound work. to_thread does not help, because a thread still contends for the interpreter. Heavy computation belongs in a process pool.
Trusting that it is fine because it works in testing. One user hides the bug; concurrency exposes it. Test the loop under load, or run with debug=True and watch for slow-callback warnings.
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “One thread means one blocker stops everything”?
What does this module say about “Watch a heartbeat stall”?
What does this module say about “The fix: stop blocking the thread”?
See how one synchronous call inside a coroutine freezes every other task, then learn the two ways to move blocking work off the loop.