When Async Is Worth It in FastAPI and When Plain def Is Fine
- Author
- Vishal Maurya
- Published on
- Reading time
- 5 min read
Overview
A FastAPI service handles a few requests fine, then under load latency spikes for every endpoint, including a trivial health check. A profile shows one slow route, and that route is async def with a requests.get(...) inside. One blocking call froze the whole server.
Async is not automatically faster. It is a tool with a specific benefit, and used wrongly it is worse than plain def.
1. What FastAPI Does With Each Kind of Route
FastAPI runs on an event loop (via Starlette and an ASGI server such as Uvicorn). It treats the two kinds of path operation functions differently:
async defroutes run directly on the event loop. While your code does notawait, nothing else on that loop can run.- Plain
defroutes are run in a thread pool, so the event loop stays free while they block.
So a sync def route that calls a blocking library is not a problem for other requests, within the limits of the thread pool. Starlette's default pool (via AnyIO) has a limited number of threads, 40 by default at the time of writing; check your version. Beyond that, requests queue.
2. The Classic Mistake
import requests
from fastapi import FastAPI
app = FastAPI()
@app.get("/slow")
async def slow():
# Blocks the event loop: no other request is served meanwhile
response = requests.get("https://example.com/api")
return response.json()
The same applies to a sync database driver, time.sleep, heavy CPU work, file reads on slow disks, and many SDKs. Any of these inside async def stops all other coroutines for the duration.
Two fixes. Either make it a plain def:
@app.get("/slow")
def slow():
response = requests.get("https://example.com/api")
return response.json()
Or use an actual async client and await it:
import httpx
@app.get("/slow")
async def slow():
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get("https://example.com/api")
return response.json()
In production, create one shared client in the app lifespan rather than one per request.
3. When Async Actually Pays Off
Async helps when the server is mostly waiting on I/O and you need high concurrency without a thread per request:
- Many concurrent requests that wait on slow upstream APIs.
- Fan-out inside one request, such as calling three services at once with
asyncio.gather. - Long-lived connections: WebSockets, server-sent events, streaming responses.
- Using libraries that are natively async end to end, for example
asyncpgor an async SQLAlchemy engine.
import asyncio
import httpx
@app.get("/dashboard")
async def dashboard():
async with httpx.AsyncClient(timeout=5) as client:
users, orders = await asyncio.gather(
client.get("https://svc-a.internal/users"),
client.get("https://svc-b.internal/orders"),
)
return {"users": users.json(), "orders": orders.json()}
4. When Plain def Is the Better Choice
Use def when:
- You use a sync ORM or driver (Django ORM style code, sync SQLAlchemy,
psycopg2). - You call sync SDKs without async versions.
- The endpoint is mostly CPU work, though see below.
- You want simpler code and the traffic does not need async scale.
A sync route backed by a threadpool is a reasonable architecture. Mixing is allowed: you can have async def for I/O fan-out routes and def for the rest in one app. Choose per endpoint, not per project.
Be careful that a sync route running in the pool has limits: the pool size caps concurrent blocking requests, and your database connection pool should be sized to match. Do not assume unlimited concurrency.
5. Bridging the Two
If you must call blocking code from an async route, move it off the loop.
from fastapi.concurrency import run_in_threadpool
# or: await asyncio.to_thread(blocking_function, arg)
@app.get("/report")
async def report():
data = await run_in_threadpool(build_report_sync)
return data
For CPU-bound work, threads do not help much because of the GIL. Use a process pool or move the work to a background worker such as Celery or a job queue, and return a job ID.
Also note that BackgroundTasks defined as def functions run in the threadpool, while async def ones run on the loop, so the same rules apply there.
6. Detect Blocking Calls
Symptoms of loop blocking: all endpoints slow together, health checks time out, and latency scales with one route's duration. To find offenders, enable asyncio debug mode in development (PYTHONASYNCIODEBUG=1), which logs slow callbacks, and review async def routes for any call that is not awaited yet does I/O. Add timeouts on every outbound call.
A simple rule: inside async def, every I/O call should be an await. If it is not, the route should probably be def.
Conclusion
Use async def when the whole path is genuinely async and concurrency on I/O matters. Use plain def when you rely on blocking libraries. The worst combination is async def containing blocking calls.
If you want an audit of a FastAPI service for blocking calls and concurrency limits, I can help. Contact me.