Unit 11.02: Blocking calls inside an async handler
A blocking call inside an async def handler stalls every other request on that worker.
Five concurrent handlers, serialised
The same wait, blocking and awaited.
The code times both.
import asyncio, time
async def blocking_handler():
time.sleep(0.05) # blocks the event loop
return "done"
async def cooperative_handler():
await asyncio.sleep(0.05) # yields to other requests
return "done"
async def measure(handler, n):
start = time.perf_counter()
await asyncio.gather(*(handler() for _ in range(n)))
return (time.perf_counter() - start) * 1000
n = 5
blocking_ms = asyncio.run(measure(blocking_handler, n))
cooperative_ms = asyncio.run(measure(cooperative_handler, n))
print(f"{n} concurrent handlers, each waiting 50ms")
print(f" blocking call inside async def : {blocking_ms:>6.0f} ms")
print(f" awaited properly : {cooperative_ms:>6.0f} ms")
print("""
The blocking version serialised every request onto one thread. Every other
caller waited, including ones that had nothing to do with this endpoint.
If a library has no async client, declare the handler `def` -- FastAPI then
runs it in a threadpool and the event loop stays free.
""")
The blocking version serialised every request onto one thread. Every other caller waited - including ones hitting completely different endpoints, because they share the event loop.
This is the most consequential mistake in async FastAPI code, and it produces a symptom that looks like a capacity problem rather than a bug.
The mistake this prevents
The mistake is declaring a handler async def and calling a synchronous database driver or HTTP library inside it. Declare it def instead and FastAPI runs it in a threadpool, leaving the loop free.
Takeaway
Never make a blocking call inside async def. If the library has no async client, declare the handler def and let FastAPI use a threadpool.
