Back to News & Insights
Web Development October 13, 2026 · 8 min read

Changing def to async def let one blocking call freeze an entire FastAPI app

The same requests.get was harmless in a def endpoint and froze the server in an async def one: an unrelated endpoint went from 1.6 ms to 34 seconds. Plus FastAPI's 40-thread cliff, why asyncio.to_thread barely helps CPU work, and a watchdog that names the blocking line.

Changing def to async def let one blocking call freeze an entire FastAPI app

Everyone who writes async Python has been told not to block the event loop. What I had never seen measured is how much damage one blocking call actually does, and the part of FastAPI's behaviour that decides whether a given blocking call does any damage at all.

The short version is that the same requests.get was completely harmless in a plain def endpoint and froze the entire server in an async def one. An unrelated endpoint that normally answers in 1.6 milliseconds took 34 seconds. The usual fixes have edges too: one of them stops working at exactly forty concurrent requests, and another barely helps at all.

Everything runs locally on Python 3.14.4, FastAPI 0.143.0, Starlette 1.7.0, uvicorn 0.54.0 and anyio 4.15.1, with uvicorn's default single worker and its default asyncio event loop.

A separate upstream service stands in for a slow third-party API: every request to it takes exactly one second. The app under test has a handful of endpoints that call it in different ways, plus a trivial one I use as a canary:

The load generator runs twenty concurrent clients against one of the slow endpoints, and while they run it measures how long the trivial /fast endpoint takes to answer, probing it every fifty milliseconds. Each case runs for fifteen seconds, three times.

| while 20 clients hit | /fast median | slow requests served in 15 s | |---|---|---| | nothing (baseline) | 1.6 ms | | | async def + httpx | 1.6 ms | 320 | | plain def + requests | 1.6 ms | 320 | | async def + requests | 34,130 ms | 35 |

Three of those rows are indistinguishable. The fourth is a different server. In fifteen seconds of probing, /fast answered exactly once, and that one answer took 34 seconds to arrive. The slow endpoint itself managed 35 requests in the time the others managed 320, which is one per second: the server had become strictly sequential.

The difference between the second and third rows is the whole story. requests.get blocks the thread it runs on until the upstream answers. In an async def endpoint, that thread is the one running the event loop, so for that second nothing else on the server can make progress, not the other nineteen slow requests and not the trivial one either. Twenty clients each holding the loop for a second turns into a queue.

A plain def endpoint is different because FastAPI never runs it on the event loop. It sends it to a thread pool, and the loop carries on serving everything else while the thread waits. The identical blocking call is harmless there.

Which means the common refactor of adding async to an existing endpoint, without also changing every library it calls, makes it worse. The code reads as more modern and behaves like a server with one thread.

Plain def endpoints being safe raised an obvious next question: safe up to what point? FastAPI's thread pool comes from anyio, and anyio's default limit is easy to check:

So I held the slow def endpoint at different concurrency levels and measured a trivial def endpoint, one that does nothing except return a constant:

| slow def requests in flight | trivial def endpoint, median | |---|---| | 20 | 1.9 ms | | 39 | 1.9 ms | | 40 | 890 ms | | 60 | 953 ms | | 100 | 1,955 ms |

There is a cliff at exactly forty. Below it, nothing is affected. At forty, every thread is busy waiting on the upstream, so the trivial endpoint has to wait for one to come free, which takes up to a second. At a hundred it waits for two rounds, and the slow requests themselves go from one second to 2.1.

Run exactly at the boundary, the result was split. Two runs out of three showed the 890 ms median, and one showed 11.9 ms with a 914 ms worst case, depending on whether a thread happened to free up just as the probe arrived. Forty is where the behaviour changes, not a precise threshold you can sit on.

async def endpoints don't use the pool at all. With a hundred slow def requests in flight, /fast still answered in 1.6 ms.

If you have a lot of slow synchronous endpoints, the limit is adjustable at startup:

With 200, a hundred concurrent slow requests left the trivial endpoint at 1.8 ms, and 150 simultaneous one-second requests all finished in 1.28 seconds. Forty threads would have needed four rounds of a second each to get through the same 150, by arithmetic rather than measurement. Threads are not free, so this trades memory for headroom rather than solving anything, but it is a one-line change that removes a cliff.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation