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.

Overview

One thread means one blocker stops everything

The event loop runs on a single thread and can only switch coroutines when the running one hits an await. So a coroutine that stops awaiting — because it called something synchronous that takes real time — holds the one thread for that whole time, and every other coroutine, however unrelated, waits.

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.

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.

Watch a heartbeat stall

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.

example_01.pyPython
Output
tick
blocker done
tick
tick
tick
tick

The 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 fix: stop blocking the thread

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.

example_02.pyPython
Output
tick
tick
tick
blocker done
tick
tick

Now 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.

When you cannot swap in an async version

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.

Why it hides so well## Why it hides so well

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.

How to find them

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.

Where it goes wrong

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.

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “One thread means one blocker stops everything”?

  2. What does this module say about “Watch a heartbeat stall”?

  3. What does this module say about “The fix: stop blocking the thread”?

Cheat sheet

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.

ASYNC PYTHON · vizlearn.in/async_python/the_blocking_call_that_freezes_the_loop.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.