Choosing wrong can silently serialize a whole worker; it's the most common FastAPI performance bug.
Each worker process runs one event loop (via Starlette and AnyIO). What happens to your function depends on how you declare it:
async defruns on the event loop. While it waits at anawait, the loop serves other requests. But a blocking call inside it, such astime.sleep,requests.get, a sync database driver or heavy computation, freezes the loop, and every request on that worker waits.- Plain
defruns in a threadpool viarun_in_threadpool, so blocking I/O only ties up one thread. The same rules apply to dependencies.
The decision rule:
- The code awaits async libraries (
httpx.AsyncClient, asyncpg, SQLAlchemy's asyncio extension,redis.asyncio): useasync def. - The code calls blocking libraries: use
def. - Trivial code with no I/O: either works, and
async defskips the thread hop.
The threadpool is AnyIO's default limiter, 40 threads per process. Under load with slow sync calls, requests queue for a free thread even though the CPU is idle. You can raise the limit at startup (anyio.to_thread.current_default_thread_limiter().total_tokens = 100), but threads cost memory and add contention; moving hot paths to async drivers usually scales further.
When an async endpoint must call one blocking function, push it off the loop with await run_in_threadpool(fn, arg) from fastapi.concurrency, or await anyio.to_thread.run_sync(fn, arg). CPU-heavy work such as image processing doesn't belong in the web process at all: threads don't run Python bytecode in parallel under the GIL, so use a process pool or a task queue.
A quick diagnostic: if /health gets slow whenever one heavy endpoint is busy, something is blocking the event loop.
Example
import asyncio, time
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/bad")
async def bad():
time.sleep(0.5) # blocks the event loop
return "bad"
@app.get("/sync")
def sync():
time.sleep(0.5) # runs in the threadpool
return "ok"
@app.get("/async")
async def non_blocking():
await asyncio.sleep(0.5) # yields to the loop while waiting
return "ok"
async def main():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
for path in ("/bad", "/sync", "/async"):
start = time.perf_counter()
await asyncio.gather(*(c.get(path) for _ in range(5)))
print(path, round(time.perf_counter() - start, 1))
asyncio.run(main()) # /bad 2.5 /sync 0.5 /async 0.5Remember
- async def runs on the event loop: never block inside it
- def runs in a 40-thread pool: safe for blocking libraries
- Use run_in_threadpool to call one blocking function from async code
- CPU-bound work belongs in processes or a task queue, not the web worker
Watch out for
- Calling requests, time.sleep or a sync DB driver inside async def.
- Making everything async def 'for speed' while still using blocking libraries.