FastAPI and the event loop
Article.
FastAPI is an ASGI application. It does not own the event loop — Uvicorn does — and your handlers are coroutines scheduled on it. Everything that surprises people about FastAPI performance follows from that one fact, including the failure where every endpoint gets slow at once and nothing in your database looks wrong.
The mechanism: how FastAPI decides where code runs
At route registration, FastAPI inspects the handler with
inspect.iscoroutinefunction — once, at import, the same moment it builds the
dependency tree.
The check unwraps functools.partial, follows __wrapped__
from decorators, and looks at the __call__ of a callable class, so
partial(async_fn), a correctly wrapped async decorator and a class with
async def __call__ all run on the loop. A plain def wrapper around an async
function does not (see the decorators note,
Decorators).
@app.get("/a")
async def a():
return await db.fetch(...) # runs ON the event loop
@app.get("/b")
def b():
return blocking_db.fetch(...) # handed to a THREAD POOLA coroutine function is awaited on the loop. A plain function is given to a worker thread so its blocking cannot stall everything else.
Three combinations, one bug
That gives three combinations, and only one of them is a bug:
| Handler | Contains | Result |
|---|---|---|
def |
blocking call | fine, the pool absorbs it |
async def |
await on async lib |
correct |
async def |
blocking call | stalls every request |
@app.get("/c")
async def c():
return requests.get(url).json() # nothing rescues thisOne slow call there freezes the entire worker, including handlers that touch nothing. The signature is p99 rising on every endpoint simultaneously while CPU sits idle and no single query looks slow — which is why it gets misdiagnosed as a network or database problem for days.
Measured: the three cases
One uvicorn worker (FastAPI 0.142.4, Starlette 1.8, uvicorn 0.54, Python 3.14.5), 50 concurrent requests to an endpoint that waits one second:
| Endpoint (each waits 1 s) | 50 requests took |
|---|---|
| awaited sleep | 1.1 s |
def, blocking |
2.1 s |
to_thread |
4.1 s |
async def, blocking |
50.2 s |
await asyncio.sleep(1) overlaps perfectly; the blocking rows use time.sleep(1). The def endpoint ran in two waves of
40 threads. asyncio.to_thread ran in four waves of 16. The blocking call in
an async def ran the requests one after another.
Two thread pools, not one
The detail that turns “use def instead” into a real capacity question.
AnyIO's limiter: 40 tokens
├── every `def` handler and sync dependency
├── run_in_threadpool(...)
└── anyio.to_thread.run_sync(...)
the loop's default executor: min(32, CPUs + 4)
├── asyncio.to_thread(...)
├── loop.run_in_executor(None, ...)
└── DNS lookups (getaddrinfo)Starlette runs sync handlers and sync dependencies through AnyIO’s limiter,
40 tokens by default. asyncio.to_thread does not use it: it goes to the
event loop’s own ThreadPoolExecutor, sized from the CPU count, which is why
it ran in waves of 16 above.
Forty concurrent sync requests saturate the first pool, and request forty-one waits — not for the database, for a thread. Latency climbs with concurrency, CPU is idle, and there is nothing slow to point at.
The ceiling
By Little’s law the ceiling is easy to state: 40 tokens at 0.2 s per sync request is 200 requests a second per worker, before the database pool is even considered (Concurrency and parallelism).
@asynccontextmanager
async def lifespan(app: FastAPI):
# the limiter belongs to the running loop: at import time this raises
# NoEventLoopError
anyio.to_thread.current_default_thread_limiter().total_tokens = 100
yieldRaise it only when the work is genuinely I/O-bound and the downstream can take the concurrency. More threads against a database with a 20-connection pool just moves the queue.
If you must call blocking code from an async handler, be explicit:
row = await anyio.to_thread.run_sync(blocking_db.fetch, key)Where the concurrency actually comes from
Async does not make one request faster. It makes a waiting request cheap, so one worker serves many at once.
Two corollaries people get wrong:
- It buys nothing for CPU-bound work. A coroutine computing a hash holds the loop exactly as a blocking call does. That needs processes.
- It does not replace worker processes.
uvicorn --workers 4runs four processes, each with its own loop. Async gives concurrency within a worker; processes give parallelism across cores. You need both on a multi-core box — see CPU-bound versus I/O-bound.
Inside one handler, concurrency is explicit:
async with asyncio.TaskGroup() as tg:
price = tg.create_task(prices.get(sku))
stock = tg.create_task(stock_svc.get(sku))
return {"price": price.result(), "stock": stock.result()}Total latency becomes the slower call rather than the sum, and a failure in either cancels the other — see TaskGroup and structured concurrency.
Where it breaks in production
Blocking work hidden behind an innocent import
The requests call above is easy to spot in review. These are not:
jwt.decode(token, key, algorithms=["RS256"]) # RSA verify: CPU, on the loop
bcrypt.checkpw(password, hashed) # deliberately slow, by design
Image.open(f).resize((256, 256)) # Pillow, CPU
json.loads(payload) # fine until the payload is 40 MBbcrypt is the sharpest: it is designed to take ~100ms, and 100ms on the loop
is 100ms during which the worker serves nobody. A login endpoint under load
takes the whole service down. Password hashing belongs in a thread.
Middleware on the hot path
BaseHTTPMiddleware is convenient and sits in front of every request. Anything
blocking inside it blocks everything, and it adds measurable overhead per
request because of how it wraps the ASGI call.
For cross-cutting work that must be fast, prefer pure ASGI middleware or a dependency — see Middleware.
Blocking during startup
@asynccontextmanager
async def lifespan(app: FastAPI):
warm_the_cache() # sync, 30 seconds
yieldNothing serves traffic until this returns, so a slow startup becomes failing readiness probes and a rolling deploy that stalls. Do the minimum synchronously and warm asynchronously afterwards.
Use lifespan rather than @app.on_event("startup"), which is deprecated.
Hidden consumers of the thread pool
Not only def endpoints take a token. A sync generator behind a
StreamingResponse is iterated in the pool, one chunk at a time; UploadFile
methods run in it; sync dependencies with yield enter and exit through it
(FastAPI gives each exit its own one-slot limiter, so cleanup cannot deadlock
waiting for a token held by the request it is cleaning up). A streaming
endpoint with a sync generator holds a token for the whole stream.
The sync driver nobody noticed
An async def handler with SQLAlchemy’s sync engine, or psycopg2 instead of
psycopg, or redis instead of redis.asyncio. The code reads as async and
blocks anyway.
The check that settles it is not code review — it is a load test with concurrency well above one. If throughput does not rise with concurrency, the loop is blocked somewhere.
Diagnosing it
Add a watchdog before you need one. The loop can tell you it is blocked:
async def loop_watchdog(threshold: float = 0.25):
while True:
start = time.perf_counter()
await asyncio.sleep(0.1)
lag = time.perf_counter() - start - 0.1
if lag > threshold:
log.warning("event loop blocked", extra={"lag_s": round(lag, 3)})If sleep(0.1) takes 0.9 seconds, something held the loop for 0.8 of them.
That single number distinguishes “the loop is blocked” from “the dependency is
slow”, which is the distinction that takes teams longest to make.
asyncio debug mode (PYTHONASYNCIODEBUG=1) logs slow callbacks and is worth
enabling in staging.
The second number tells the other failure apart: thread-pool saturation. AnyIO reports it directly:
stats = anyio.to_thread.current_default_thread_limiter().statistics()
stats.borrowed_tokens, stats.tasks_waiting # in use, and queued for a threadExport both as gauges next to the loop lag. A blocked loop shows lag with an
idle pool; a saturated pool shows tasks_waiting above zero with a healthy
loop. Django has the same split: an async view that calls the sync ORM raises
SynchronousOnlyOperation, and sync_to_async(thread_sensitive=True) runs
such calls on a single shared thread, which serialises them
(Async Django, Channels, and Async ORM).
The trade not taken
Async is not free. It removes def from your vocabulary, forces async
libraries for every dependency, and makes a single mistake catastrophic rather
than local — a blocking call in a threaded server slows one request, and on an
event loop it slows all of them.
If your service is not I/O-concurrency-bound, sync is a legitimate choice.
A FastAPI app with all-def handlers behind enough workers is straightforward,
uses ordinary libraries, and fails locally rather than globally. The same is
true of Flask or Django with a decent worker count.
Async earns its complexity when you hold many concurrent connections that are mostly waiting — a chat backend, an API gateway, anything streaming, anything fanning out to several services per request. Reaching for it because it is the modern default is how teams acquire the failure mode above without needing the capability.
Related
Interview angle 6
- “What happens if you use a sync database driver in an
async defroute?” — it blocks the event loop for the duration, stalling every other request in that worker. Use an async driver, declare the routedefso it goes to the threadpool, or push the call to a thread explicitly.- Follow-up: “How would that look in your metrics?” — p99 rising on every endpoint at once, including ones touching nothing, with idle CPU and no slow query. It is routinely misdiagnosed as a network or database problem.
- “Is the threadpool unlimited?” — no: forty tokens by default, shared by
every sync handler, sync dependency and
run_in_threadpoolcall. Past forty concurrent, requests wait for a thread rather than for the database.asyncio.to_threadis a different pool, the loop’s executor (16 threads on a 12-CPU machine).- Follow-up: “Fifty concurrent requests to a
defendpoint that sleeps one second: how long?” — about two seconds on one worker: two waves of 40. The same sleep inside anasync deftakes fifty seconds, one request at a time.
- Follow-up: “Fifty concurrent requests to a
- “Does
async defmake the app faster?” — it makes waiting cheap, so one worker serves many concurrent I/O-bound requests. One request is no faster, and CPU-bound work gets nothing.- Follow-up: “So do you still need multiple workers?” — yes. Each worker has one loop and one core’s worth of Python execution. Async is concurrency within a worker; processes are parallelism across cores.
- “Which blocking calls do people miss?” — CPU work that does not look like
I/O:
bcrypt, RSA signature verification, image resizing, parsing a very large payload.bcryptis the worst because it is deliberately slow, so a login endpoint under load can stall the whole worker. - “How would you prove the loop is blocked?” — a watchdog coroutine that sleeps 100ms and measures the overshoot. Lag is the direct measure of loop-blocking, and it separates that from a slow dependency.
- “When would you not use async?” — when the service is not holding many concurrent waiting connections. Sync handlers behind enough workers use ordinary libraries and fail one request at a time rather than all of them.