“Just make it async def” is the advice every FastAPI developer hears first, and it’s the reason so many production incidents start with one slow endpoint quietly taking every other endpoint down with it. The instinct makes sense — async def looks like the “fast” option and def looks like the legacy one — but that framing gets the actual failure mode backwards. async def isn’t free concurrency. It’s a promise to the event loop that this function will never sit and block, and FastAPI has no way to check whether you kept that promise until it’s already too late.
The real story is more specific than “use async everywhere,” and understanding it is the difference between an API that degrades gracefully under load and one where a single requests.get() call inside an async def handler freezes every concurrent user on the server, not just the one who made that request. This post walks through what actually happens when a coroutine blocks, why def routes behave completely differently, how to spot the blocking call hiding in a library you didn’t audit, and what to actually do about it once you’ve found it.
You’ll learn:
- What FastAPI’s event loop actually is, and why it can only do one thing at a time
- The real difference between
async defanddefroutes, and why FastAPI treats them completely differently under the hood - How the threadpool
defroutes run in protects the event loop, and why that same safety net doesn’t exist forasync def - The blocking calls that hide inside
async defhandlers — synchronous DB drivers,requests,time.sleep, CPU-bound work - How to actually diagnose a blocked event loop in a running service, not just guess
- The concrete fixes:
run_in_executor, async-native libraries, and when to just usedef
Table of Contents
- The Basics
- async def vs def: What FastAPI Actually Does
- The Threadpool Safety Net
- Blocking Calls Hiding Inside async def
- Diagnosing a Blocked Event Loop
- Fixing It
- A Worked Example
- Common Pitfalls
- Production Best Practices
The Basics
One Event Loop, One Thread, No Exceptions
FastAPI is built on Starlette, which is built on asyncio. In the default configuration, a single worker process runs a single event loop on a single thread, and every async def coroutine in your app — every request handler, every dependency, every middleware — takes turns running on that one thread. “Taking turns” only works if each coroutine periodically yields control back to the loop, which happens at every await. Between one await and the next, that coroutine owns the thread completely. Nothing else in the process runs — not other requests, not health checks, not the framework’s own internals — until it either finishes or hits another await.
This is the entire mental model you need: an async def function that never awaits anything, or that calls something synchronous and slow without awaiting it, doesn’t just run slowly — it stops every other in-flight request cold for as long as it takes.
Why This Surfaces as “Random” Slowness
The symptom rarely looks like “this one endpoint is slow.” It looks like a completely unrelated, normally-fast endpoint occasionally taking three seconds for no visible reason — because for those three seconds, it was queued behind someone else’s blocking call on the same thread. This is what makes the bug hard to find: the slow request and the affected request are usually different endpoints, so profiling the affected endpoint in isolation shows nothing wrong with it at all.
async def vs def: What FastAPI Actually Does
FastAPI treats the two signatures completely differently, and the difference is the whole story:
@app.get("/fast-if-truly-async")
async def get_data():
# Runs directly on the event loop thread.
# Every `await` here yields control back to the loop.
result = await some_async_db_call()
return result
@app.get("/runs-in-threadpool")
def get_data_sync():
# FastAPI automatically dispatches this to a worker
# thread from Starlette's threadpool — it never runs
# on the event loop thread at all.
result = some_blocking_db_call()
return result
An async def route runs directly on the event loop. FastAPI trusts that you’ve written a function that never blocks the thread for a meaningful stretch of time without an await in between. A def route, by contrast, is automatically offloaded to a separate worker thread via run_in_threadpool — you get correct, non-blocking behavior without writing a single line of async code, because the blocking work happens somewhere else entirely.
This is precisely backwards from what most people guess on first exposure to FastAPI: def isn’t the “slow, legacy” option. For a genuinely synchronous piece of work — a call to a library with no async equivalent, some CPU-light blocking I/O — plain def is often the safer choice, because it can’t accidentally block the loop even if you get the internals wrong.
The Threadpool Safety Net
Starlette’s threadpool (backed by anyio) defaults to a bounded worker pool — historically 40 threads — that def routes and def dependencies run in. Each request to a def route grabs a thread, does its blocking work there, and hands the result back to the event loop when done. The event loop itself never sits waiting on that call; it’s free to keep servicing other requests concurrently.
This is a real safety net, but it’s not unlimited. If your service takes far more than 40 concurrent slow synchronous requests, later ones queue behind earlier ones waiting for a free thread — a different failure mode than blocking the loop, but still a bottleneck worth knowing about. It’s tunable via Starlette/anyio’s thread limiter, but the more durable fix at real scale is usually to reduce how much synchronous, thread-hungry work you’re doing in the first place, not to keep raising the pool size.
The threadpool only kicks in for def. Nothing catches a blocking call made inside async def — that’s the entire reason this bug class exists.
Tuning the Threadpool Limiter
If you do need more than the default worker-thread ceiling — a burst of synchronous file uploads, for instance — anyio exposes a capacity limiter you can raise at startup:
import anyio
from anyio import to_thread
@app.on_event("startup")
async def raise_threadpool_limit():
limiter = to_thread.current_default_thread_limiter()
limiter.total_tokens = 100
Treat this as a stopgap, not a fix. Every extra thread adds memory and context-switch overhead, and it does nothing about a genuinely blocked event loop — it only helps when the bottleneck is thread availability for def routes, which is a different problem from the one this post is mainly about.
Why Adding More Uvicorn Workers Doesn’t Fix This
A common but wrong instinct is to scale out of a blocked-loop incident by adding more Uvicorn/Gunicorn worker processes. Each worker process does get its own event loop, so more workers do add more total capacity — but within any single worker, the exact same blocking behavior happens: one slow async def call still stalls every request currently assigned to that worker’s loop. More workers reduce the blast radius per incident (only 1/Nth of your traffic hits the stalled worker at a time), but they don’t address the root cause, and they multiply infrastructure cost to paper over a bug that a five-line code fix would resolve for free. Treat extra workers as a resilience buffer, never as the actual solution to a blocking call.
Blocking Calls Hiding Inside async def
The dangerous pattern is always the same shape: an async def handler that calls something synchronous without wrapping it, so the “async” function blocks the loop exactly as hard as a def function would, minus the threadpool that would have saved it.
import time
import requests # synchronous HTTP client
@app.get("/danger")
async def danger():
time.sleep(2) # blocks the entire event loop for 2s
resp = requests.get(EXTERNAL) # blocks for however long the network takes
return resp.json()
Every request in flight anywhere in the process stalls for the combined duration of both calls. The usual offenders:
- Synchronous HTTP clients —
requests, orhttpx.Client(nothttpx.AsyncClient) — insideasync def. - Synchronous database drivers —
psycopg2, the sync mode of SQLAlchemy — called directly instead of through an async driver. This is exactly the trap covered in depth in Async SQLAlchemy Sessions in FastAPI, Done Right: mixing a sync engine into an async codebase reintroduces this exact blocking behavior. time.sleep()instead ofasyncio.sleep()— an easy typo that has zero effect until it’s under concurrent load.- CPU-bound work — image resizing, PDF generation, heavy
pandastransforms, cryptographic hashing. These block regardless of which client library you use, because the cost is computation, not I/O — there’s no async version of “the CPU is busy,” and no amount ofawaitfixes it. - File I/O on local disk —
open(),.read(),.write()are all synchronous system calls unless you route them throughaiofilesor a threadpool.
None of these raise an error. They just quietly make every other request on the process wait its turn.
Diagnosing a Blocked Event Loop
Guessing which endpoint is the culprit rarely works — you need actual signal from the running process.
1. asyncio debug mode. Running with PYTHONASYNCIODEBUG=1 (or asyncio.run(main(), debug=True)) makes asyncio log a warning whenever a callback takes longer than 100ms to run, which is exactly the symptom of a blocked loop.
PYTHONASYNCIODEBUG=1 uvicorn app.main:app
2. Server.log_slow_callbacks / manual loop instrumentation. You can attach a periodic heartbeat coroutine that measures its own scheduling delay — if a heartbeat that should fire every 100ms is regularly firing late, something is monopolizing the loop:
import asyncio, time
async def loop_monitor():
while True:
start = time.monotonic()
await asyncio.sleep(0.1)
drift = time.monotonic() - start - 0.1
if drift > 0.05:
print(f"Event loop blocked for ~{drift:.2f}s")
3. Load testing with a mixed endpoint set. Hit a known-fast endpoint and a suspected-slow one concurrently with a tool like locust or hey. If the fast endpoint’s latency degrades in lockstep with the slow one’s load, you’re looking at loop contention, not per-endpoint slowness.
4. APM traces with thread/task context. Tools like Datadog APM or OpenTelemetry can show you whether a span is running on the event loop thread or a threadpool worker — a slow async def span with no child spans is a strong sign it’s doing blocking work synchronously.
5. py-spy dump against the live process. py-spy attaches to a running Python process without restarting it and prints every thread’s current stack. On a stuck server, run py-spy dump --pid <uvicorn-worker-pid> and look for a single thread sitting inside time.sleep, a synchronous socket call, or a database driver’s C extension — that’s your blocking call, caught in the act, in production, with no code changes required to reproduce it.
pip install py-spy
py-spy dump --pid $(pgrep -f "uvicorn app.main:app" | head -1)
Fixing It
Once you’ve identified the blocking call, there are three real options, in order of preference:
1. Use the async-native version of the library. requests → httpx.AsyncClient; psycopg2 → asyncpg or SQLAlchemy’s async engine; time.sleep → asyncio.sleep. This is almost always the right fix — it removes the blocking call instead of working around it.
import httpx
@app.get("/fixed")
async def fixed():
async with httpx.AsyncClient() as client:
resp = await client.get(EXTERNAL)
return resp.json()
2. Offload to a thread explicitly with run_in_threadpool or run_in_executor. When no async equivalent exists — a legacy SDK, a C-extension-backed library — push the blocking call to a worker thread manually instead of running it on the loop:
from starlette.concurrency import run_in_threadpool
@app.get("/legacy-sdk")
async def legacy_sdk():
result = await run_in_threadpool(legacy_blocking_call, arg1, arg2)
return result
3. Just make it a def route. If a handler is fundamentally synchronous — it calls one blocking SDK and does nothing else concurrent — there’s no benefit to forcing it into async def. Let FastAPI’s threadpool handle it; that’s exactly what it’s for.
For genuinely CPU-bound work (image processing, heavy computation), neither threads nor async def actually help, because Python’s GIL means threads don’t give you parallelism for CPU-bound code — you need ProcessPoolExecutor, or to move the work out of the request path entirely into a background worker, which is the decision covered in FastAPI BackgroundTasks vs Celery: Picking the Right One.
A Worked Example
To see the effect directly, compare two versions of the same endpoint under concurrent load. Both simulate a 1-second blocking dependency; both are hit 20 times concurrently with httpx from a test script.
# blocking.py
@app.get("/blocking")
async def blocking():
time.sleep(1) # synchronous sleep inside async def
return {"ok": True}
# fixed.py
@app.get("/fixed")
async def fixed():
await asyncio.sleep(1) # yields control back to the loop
return {"ok": True}
Twenty concurrent requests to /blocking complete serially — roughly 20 seconds total, because each time.sleep(1) fully occupies the only thread doing work before the next request can even start being processed. The same 20 concurrent requests to /fixed complete in roughly 1 second total, because asyncio.sleep yields control immediately and all twenty coroutines are suspended and resumed together by the loop. Nothing about the endpoint’s own advertised latency changed — only whether it actually cooperated with everything else running in the process.
The gap widens, not narrows, as concurrency increases. At ten concurrent requests, /blocking might still look tolerable in a quick manual test — roughly ten seconds isn’t alarming if nobody’s watching the clock closely. At the traffic levels a real production service sees, the same linear-serialization behavior turns into a p95 latency chart that climbs in a straight line with request volume instead of flattening out the way a healthy async service’s should. That shape — latency scaling linearly with concurrent load instead of staying roughly flat until real resource limits are hit — is one of the clearest fingerprints of a blocked event loop in a dashboard, and it’s usually visible well before anyone manually diagnoses the root cause.
Common Pitfalls
Mistake: assuming async def is always faster. For a route with a single synchronous, non-blocking-in-practice call, def and the threadpool are simpler and just as safe. Solution: default to def for genuinely synchronous work, and reserve async def for code paths that actually await something.
Mistake: mixing a sync ORM session into an async route. Calling a sync SQLAlchemy session from inside async def blocks the loop exactly like any other sync call. Solution: use an async engine and session consistently, as covered in the SQLAlchemy sessions post.
Mistake: not noticing CPU-bound work is the actual bottleneck. Switching a CPU-heavy handler to async def or wrapping it in run_in_threadpool doesn’t help — the GIL still serializes CPU work across threads. Solution: use ProcessPoolExecutor for CPU-bound work, or move it out of the request/response cycle entirely.
Mistake: debugging in isolation. Testing the suspected-slow endpoint alone, with no concurrent load, never reproduces the symptom — the whole bug depends on contention. Solution: always reproduce with concurrent requests hitting multiple endpoints at once.
Mistake: raising the threadpool size as a first response. This masks def-route thread starvation but does nothing for a blocked event loop caused by async def. Solution: diagnose which failure mode you actually have before reaching for a config change.
Production Best Practices
- Default new synchronous integrations to
def, notasync def. Let FastAPI’s threadpool do its job instead of hand-rolling the same protection. - Audit every
async deffor a call that isn’t awaited. A quick grep forrequests.,time.sleep(, or synchronous driver imports inside async handlers catches most real incidents before they ship. - Add loop-lag monitoring in production, not just locally. A lightweight heartbeat coroutine logging scheduling drift costs almost nothing and catches regressions instantly.
- Load test with concurrency, not single requests. Loop contention is invisible under sequential testing by definition.
- Get the app’s foundational layout right first. A structure that keeps route handlers thin — as covered in FastAPI Project Structure That Survives Growth — makes it much easier to spot a stray blocking call, because business logic lives in one obvious place instead of scattered across handlers.
Wrapping Up
A blocked event loop isn’t a mysterious performance ceiling — it’s a specific, traceable consequence of one coroutine holding the only thread the whole process depends on. async def vs def was never about which one is “modern”; it’s about whether you’re handing FastAPI code that genuinely cooperates with the loop, or code that only looks like it does. Audit for blocking calls before load testing surfaces them for you, and when in doubt, let the threadpool handle it — def is not the fallback option, it’s frequently the correct one.
Have you actually load-tested your async def routes under concurrency, or just assumed the keyword was doing the work for you?
