In this post
What this post covers
FastAPI runs async def endpoints on the event loop, one thread juggling every
request, and plain def endpoints in a thread pool. Both can be fast. The disaster
is a blocking call inside async def: time.sleep, requests.get,
a synchronous database driver, a heavy loop. It freezes the loop, and every request in that process
waits, including health checks. This part measures exactly that, so you never have to learn it in an
incident.
One brilliant waiter, and a team of cooks
The event loop is a single waiter serving every table. They take an order, hand it to the kitchen, and
move on while it cooks: that is await. If the waiter stands at one table staring at a slow
toaster (a blocking call), nobody else gets served. def endpoints go to a team of cooks (the
thread pool, 40 by default), each allowed to wait on their own dish without holding up the waiter.
1. Where your code runs
2. Measure it
Six endpoints that “do 0.2 s of waiting” or some CPU work in different ways. Each gets a burst of concurrent
requests while a /health probe measures how long an unrelated, trivial request waits. The client
talks to the app in-process over ASGI, so the numbers show the app’s own behaviour:
the same wait, four ways, and what it does to everyone else
import asyncio
import time
import anyio.to_thread
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/async-await")
async def async_await():
await asyncio.sleep(0.2) # non-blocking wait: the loop serves others meanwhile
return "ok"
@app.get("/async-blocking")
async def async_blocking():
time.sleep(0.2) # BLOCKING call inside async def: the loop is frozen
return "ok"
@app.get("/sync-blocking")
def sync_blocking():
time.sleep(0.2) # blocking, but in a def endpoint: runs in the thread pool
return "ok"
@app.get("/cpu-async")
async def cpu_async():
return sum(i * i for i in range(2_000_000)) # pure-Python CPU work on the loop
@app.get("/cpu-sync")
def cpu_sync():
return sum(i * i for i in range(2_000_000)) # the same work in a thread
@app.get("/health")
async def health():
return "ok"
async def burst(client, path, n):
start = time.perf_counter()
await asyncio.gather(*(client.get(path) for _ in range(n)))
return time.perf_counter() - start
async def health_latency_during(client, path, n):
async def probe():
start = time.perf_counter()
await asyncio.sleep(0.05) # fire the probe while the burst is running
await client.get("/health")
return time.perf_counter() - start - 0.05
results = await asyncio.gather(probe(), *(client.get(path) for _ in range(n)))
return results[0]
async def main():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test", timeout=60) as client:
await client.get("/health")
print(f"{'endpoint':16}{'20 concurrent':>15}{'/health waits':>16}")
for path in ("/async-await", "/async-blocking", "/sync-blocking"):
total = await burst(client, path, 20)
waited = await health_latency_during(client, path, 10)
print(f"{path:16}{total:13.2f} s{waited * 1000:13.0f} ms")
for path in ("/cpu-async", "/cpu-sync"):
waited = await health_latency_during(client, path, 4)
print(f"{path:16}{'':15}{waited * 1000:13.0f} ms")
limit = anyio.to_thread.current_default_thread_limiter().total_tokens
print(f"\nthread pool size: {limit}; 60 concurrent /sync-blocking: {await burst(client, '/sync-blocking', 60):.2f} s")
asyncio.run(main())
Output
endpoint 20 concurrent /health waits
/async-await 0.21 s 9 ms
/async-blocking 4.03 s 1964 ms
/sync-blocking 0.24 s 15 ms
/cpu-async 1179 ms
/cpu-sync 92 ms
thread pool size: 40; 60 concurrent /sync-blocking: 0.44 s
| Result | Why |
|---|---|
/async-await: 20 requests in ~0.2 s | all twenty waits overlap on one thread; this is what async is for |
/async-blocking: ~4 s, and /health waits ~2 s | time.sleep holds the loop, so requests run one at a time and unrelated requests queue behind them. In production this looks like random, service-wide latency spikes and failing health checks. |
/sync-blocking: ~0.2 s, health unaffected | each request blocks its own pool thread, and the loop stays free |
| 60 sync requests take two "waves" | the pool has 40 threads; request 41 waits for one to free up |
CPU in async def stalls health for about a second; in def, far less | on the loop, nothing else runs until the work finishes. In a thread, the GIL switches every few milliseconds, so the loop still gets turns, just slowed down. CPU work in threads still does not run in parallel: total throughput is one core. |
3. The decision rules
| Your endpoint does… | Declare it |
|---|---|
async I/O only: await on httpx, asyncpg, SQLAlchemy async, Redis asyncio, LLM SDK async clients | async def |
blocking I/O: requests, a sync DB driver, boto3, file-heavy work, a legacy SDK | def (FastAPI puts it in the thread pool) |
| a mix: mostly async, one blocking library call | async def + await run_in_threadpool(blocking_fn, ...) |
| trivial work, no I/O (return a constant, simple maths) | async def: avoids a thread hop per request |
| heavy CPU (image resize, big parsing, ML inference in Python) | a process pool or a task queue, never the event loop |
calling a blocking library from async code, the right way
import asyncio
import time
import httpx
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
def legacy_pdf_render(order_id: int) -> str: # a blocking library you cannot change
time.sleep(0.2)
return f"invoice-{order_id}.pdf"
app = FastAPI()
@app.get("/invoices/{order_id}")
async def invoice(order_id: int):
name = await run_in_threadpool(legacy_pdf_render, order_id) # off the loop, into the pool
return {"file": name}
async def main():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
start = time.perf_counter()
results = await asyncio.gather(*(client.get(f"/invoices/{i}") for i in range(10)))
print([r.json()["file"] for r in results][:3], "…", f"10 requests in {time.perf_counter() - start:.2f} s")
asyncio.run(main())
Output
['invoice-0.pdf', 'invoice-1.pdf', 'invoice-2.pdf'] … 10 requests in 0.23 s
4. CPU-bound work: processes, not threads
a process pool created in lifespan, used from async endpoints
import asyncio
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
def score_risk(seed: int) -> int: # pure-Python CPU work
return sum((i * seed) % 7 for i in range(3_000_000))
@asynccontextmanager
async def lifespan(app):
with ProcessPoolExecutor(max_workers=2) as pool: # separate processes, separate GILs
yield {"pool": pool}
app = FastAPI(lifespan=lifespan)
@app.get("/risk/{seed}")
async def risk(seed: int, request: Request):
loop = asyncio.get_running_loop()
return {"score": await loop.run_in_executor(request.state.pool, score_risk, seed)}
@app.get("/health")
async def health():
return "ok"
if __name__ == "__main__": # required: pool processes re-import this file
with TestClient(app) as client, ThreadPoolExecutor(2) as callers:
client.get("/risk/1") # warm up the worker processes
start = time.perf_counter()
pending = [callers.submit(client.get, f"/risk/{seed}") for seed in (3, 5)]
time.sleep(0.05)
probe = time.perf_counter()
client.get("/health") # while both CPU jobs are running
waited = time.perf_counter() - probe
scores = [f.result().json()["score"] for f in pending]
print(f"scores {scores} in {time.perf_counter() - start:.2f} s | /health answered in {waited * 1000:.0f} ms")
Output
scores [9000000, 8999999] in 0.62 s | /health answered in 2 ms
The CPU work ran in two other processes while the event loop stayed responsive. For heavy or long jobs, go further and move the work out of the web process entirely, into a task queue (part 13), so a spike of expensive requests cannot starve your API.
5. Tuning and gotchas
The thread pool size
40 by default (anyio.to_thread.current_default_thread_limiter()). If many def endpoints block on slow I/O, raise it in lifespan (limiter.total_tokens = 100). Keep it within what your database pool can serve: 100 threads fighting over 10 connections just moves the queue.
Dependencies follow the same rules
A def dependency runs in the pool, and an async def one runs on the loop. A blocking call in an async dependency (a sync Redis client, requests) freezes the loop exactly the same way.
Async all the way down
An async def endpoint is only non-blocking if everything it awaits is truly async. One sync ORM call inside it undoes the whole design.
Find blocking calls
Run with PYTHONASYNCIODEBUG=1 (asyncio logs "Executing … took 0.2 seconds" for slow callbacks), watch p99 latency of trivial endpoints, and profile with py-spy (part 22).
Recap
async defruns on the event loop;defruns in a 40-thread pool.- A blocking call in
async defstalls every request in the process, measured here as a 2-second health check. - Choose by what the code awaits: async libraries →
async def; blocking libraries →deforrun_in_threadpool. - CPU-heavy work → a process pool or a task queue; the GIL makes threads the wrong tool.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.