TL;DR: An async def endpoint runs directly on the event loop. If it calls anything that blocks (time.sleep, requests.get, a sync database driver, heavy CPU work), the loop cannot serve any other request until that call returns. The fix is to either declare the endpoint with plain def so the framework runs it in a thread pool, push the blocking call into a thread with asyncio.to_thread, or switch to a genuinely async client. The symptom that sends people searching is odd: health checks time out and p99 explodes while CPU looks fine.
What does the symptom look like?
The pattern I see over and over: an endpoint is fast in isolation, and a load test or a traffic burst makes unrelated endpoints slow, including /health. Uptime probes fail, the load balancer marks the instance unhealthy, and the instance restarts under load, which makes things worse.
Here is a minimal reproduction. Python 3.11+, with fastapi, uvicorn and httpx installed:
# app.py
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {"ok": True}
@app.get("/slow-async")
async def slow_async():
time.sleep(0.5) # blocks the event loop
return {"done": True}
@app.get("/slow-sync")
def slow_sync():
time.sleep(0.5) # runs in a worker thread
return {"done": True}
Run it with uvicorn app:app and fire ten concurrent requests at each slow route. Against /slow-async, the ten requests are served one after another, so the last one waits roughly ten times 0.5 seconds. That is arithmetic, not a benchmark: a single-threaded loop can only do one blocking call at a time. During that window /health also waits, because it is queued behind them on the same loop.
# hammer.py
import asyncio, time, sys
import httpx
async def main(path: str, n: int = 10):
async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=30) as c:
start = time.perf_counter()
# fire n slow requests, then probe /health while they are in flight
slow = [asyncio.create_task(c.get(path)) for _ in range(n)]
await asyncio.sleep(0.1)
h0 = time.perf_counter()
await c.get("/health")
print(f"/health took {time.perf_counter() - h0:.2f}s while {path} was busy")
await asyncio.gather(*slow)
print(f"all {n} done in {time.perf_counter() - start:.2f}s")
asyncio.run(main(sys.argv[1]))
Run python hammer.py /slow-async and then python hammer.py /slow-sync. The first shows /health stuck for seconds; the second answers it almost immediately.
Takeaway: async def is a promise that you never block, and the framework cannot enforce it for you.
Why does def work when async def doesn't?
FastAPI (via Starlette and AnyIO) treats the two kinds differently. A plain def endpoint is dispatched to a worker thread pool, so a blocking call occupies a thread, not the loop. An async def endpoint is awaited on the loop itself. As of late 2026 the AnyIO thread limiter defaults to 40 concurrent threads, so a def endpoint is not free of limits either: the 41st concurrent blocking request queues.
The common mistake is the well-meaning "everything should be async" refactor. Converting def to async def without converting the calls inside it makes the endpoint worse, because you moved it off the thread pool onto the loop.
Takeaway: converting def to async def is a performance regression unless every call inside it was converted too.
How do I find the blocking call?
Do not guess. Two tools find it quickly.
1. asyncio debug mode. Start the server with PYTHONASYNCIODEBUG=1 uvicorn app:app. The loop logs any callback that holds it longer than slow_callback_duration (0.1 s by default), with the task and the coroutine involved:
Executing <Task pending name='Task-7' coro=<slow_async() running at app.py:13> ...> took 0.501 seconds
That line names the endpoint. Debug mode adds overhead, so use it in staging or a local repro, not on a hot production box.
2. A sampling profiler. If you cannot reproduce locally, py-spy dump --pid <pid> shows where each thread currently is. When the loop thread sits in time.sleep, socket.recv, or a driver's execute, that is your culprit. The honest limitation of py-spy: it needs permission to attach to the process (ptrace on Linux, root on macOS), which is often locked down in containers.
The usual suspects, in the order I find them: requests inside an async handler, a sync ORM session or psycopg2 call, open().read() on large files, json.loads on multi-megabyte payloads, password hashing (bcrypt/argon2 are deliberately slow), and image or PDF processing.
Takeaway: the slow-callback log turns "something is blocking" into a file and line number in one run.
Which fix should I use?
| Situation | Fix | Trade-off |
|---|---|---|
Handler is mostly sync code (sync ORM, requests) |
Change async def to def
|
Limited by the thread pool size; no await inside |
| One blocking call inside an otherwise async handler | await asyncio.to_thread(fn, ...) |
Same thread pool limits; code must be thread-safe |
| HTTP calls to other services | Use an async client such as httpx.AsyncClient
|
Needs one shared client, not one per request |
| CPU-bound work (hashing, image resize, big parsing) | Process pool or a job queue | Serialization cost; more moving parts |
For the one-call case:
import asyncio
import bcrypt
from fastapi import FastAPI
app = FastAPI()
def _hash(password: bytes) -> bytes:
return bcrypt.hashpw(password, bcrypt.gensalt())
@app.post("/signup")
async def signup(password: str):
hashed = await asyncio.to_thread(_hash, password.encode())
return {"hash_len": len(hashed)}
For outbound HTTP, create the client once at startup rather than per request:
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(5.0))
yield
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/proxy")
async def proxy(request: Request):
r = await request.app.state.http.get("https://example.com")
return {"status": r.status_code}
If you want an HTTP client that works in both sync and async code with the same API, httpx is the one I reach for; its drawback is that it is a bit slower than lower-level clients such as aiohttp on very high request rates, and the default timeouts differ from requests, so set them explicitly.
CPU-bound work is the case threads do not rescue: threads share the GIL, so heavy pure-Python computation in to_thread still starves other Python threads (C extensions that release the GIL, such as bcrypt, are fine). Use ProcessPoolExecutor via loop.run_in_executor or hand it to a worker queue.
Takeaway: match the fix to the kind of blocking: threads for I/O and GIL-releasing libraries, processes or queues for pure-Python CPU work.
Can running more workers hide the problem?
Yes, and that is why it survives so long. uvicorn --workers 4 (or Gunicorn with Uvicorn workers) gives you four independent loops, so one stuck loop no longer stalls everything. But four workers each blocked by one slow request is just a smaller version of the same outage. More workers are good capacity planning and bad bug-hiding; fix the block first, then size the workers.
If you want the guard rail rather than the fix, Uvicorn's --timeout-keep-alive and your proxy's upstream timeout will at least turn a silent stall into a visible 504, which is easier to alert on.
Takeaway: extra workers raise the number of requests a blocking call can hurt at once; they do not remove the block.
FAQ
Why is my FastAPI app slow when I use async def?
Almost always because something inside the handler blocks the event loop, such as requests, a sync database driver, or time.sleep. Run with PYTHONASYNCIODEBUG=1 to see which coroutine holds the loop.
Should I use def or async def in FastAPI?
Use async def only when every I/O call inside it is awaited. Use plain def when you depend on sync libraries; FastAPI runs it in a thread pool.
Does asyncio.to_thread make blocking code non-blocking?
It moves the call off the event loop onto a worker thread, so other requests keep being served. It does not speed up the call, and pure-Python CPU work still contends for the GIL.
Bottom line
If you use sync libraries, write plain def endpoints and stop worrying. If you are committed to async def, treat every library call as guilty until proven awaitable, and keep asyncio debug mode in your staging setup so a new blocking dependency gets caught in review rather than at 2 a.m. For pure CPU work, move it out of the web process entirely.
Top comments (0)