FastAPI `def` vs `async def`: When Your Endpoint Blocks Everything
Ten concurrent 0.5 s sleeps: async def + time.sleep took 5016.7 ms and made /ping sit at p50 450.7 ms. Plain def and await asyncio.sleep both finished around 0.5 s with /ping under 2 ms.

FastAPI def vs async def: When Your Endpoint Blocks Everything
I pointed ten concurrent requests at three FastAPI endpoints that each sleep for half a second. Same Uvicorn worker, same machine, same sleep. Only the function signature and the sleep call changed. Here is the wall time for all ten to finish, and what a cheap /ping saw while they ran:
/bad async def + time.sleep wall 5016.7 ms ping p50 450.7 ms
/ok-def plain def + time.sleep wall 529.5 ms ping p50 1.8 ms
/ok-await async def + await asyncio.sleep wall 510.1 ms ping p50 1.9 ms
The "bad" endpoint did not fail. Every request returned 200. It just held the event-loop thread for each half-second sleep, so the ten requests ran one after another (~5 s) and /ping sat behind them at a p50 of 450.7 ms. That is the number that kills a Kubernetes liveness probe, not the 200 on the slow route.
The other two finished in about one sleep (~0.5 s) and left /ping under 2 ms. Plain def got there because FastAPI ran each call on a threadpool worker. async def + await asyncio.sleep got there because the await yielded and the loop interleaved the ten waits.
Every number below is pasted from one run of the code in this post on Python 3.13.5, FastAPI 0.162.0, uvicorn 0.54.0, --workers 1. Absolute milliseconds move between runs; the shape does not.
The short version
async defmeans: this coroutine runs on the event-loop thread. FastAPI trusts you not to block. There is no inspection of the body.- Plain
defmeans: FastAPI (via Starlette / AnyIO) runs the function in a bounded threadpool and awaits the result. The loop stays free. time.sleep,requests.get, a sync ORM call, or a sync cloud SDK insideasync deffreezes every other request on that worker, including health checks.- Prefer
await asyncio.sleep(or a real async library) insideasync def. Prefer plaindefwhen the library is sync. Preferawait run_in_threadpool(...)/await asyncio.to_thread(...)when the endpoint is mostly async but one call is sync. - Unsure? Use
def. A misjudgedasync deffails under concurrency in a way that is hard to see from a single-request test.
What the top results cover, and what they skip
Before writing, I read the top Google results for "fastapi def vs async def blocking event loop" (6 Nov 2026):
| Result | Good at | Missing |
|---|---|---|
| FastAPI docs — Concurrency and async / await | Clear rule of thumb (await → async def, sync library → def, unsure → def); explains the threadpool for plain def |
No measured concurrent run, no health-check / ping latency |
| OneEnv — Blocking calls in async endpoints | Best mental model of the loop vs threadpool; table of hidden blockers; offload options | Illustrative latency math is labelled assumed, not a pasted run; no /ping during the stall |
| Lane Parton — Async vs Sync in FastAPI | Strong production framing (probes fail, pods restart); simple rulebook | Conceptual; no numbers |
| fastapi/fastapi discussion #4358 | Confirms from maintainers: async def + sync I/O blocks the server |
Issue thread, not a runnable demo |
| FastAPI Patterns — async def vs def performance | Measured wall times for the four signature × I/O combinations | Does not probe a cheap endpoint during the stall, which is what production notices first |
So the gap is a small app you can run, with the wall time and the /ping latency during the burst pasted from the same run. That is what this post is.
Setup
| What | Value |
|---|---|
| Runtime | Python 3.13.5 on Linux x86_64 |
| Framework | FastAPI 0.162.0, uvicorn 0.54.0, one worker |
| Client | httpx 0.28.1 (AsyncClient, 10 concurrent GETs) |
| Work | each work endpoint sleeps 0.5 s |
| Probe | /ping every 50 ms while the burst is in flight |
| Run it | cd into the folder below and run ./run.sh |
The app
Three work endpoints, one probe. The only differences are the signature and whether the sleep yields:
# app.py — three ways to "sleep 0.5 s" in FastAPI
from __future__ import annotations
import asyncio
import time
from fastapi import FastAPI
SLEEP_S = 0.5
app = FastAPI(title="def vs async def demo")
@app.get("/ping")
async def ping():
return {"ok": True, "t": time.perf_counter()}
@app.get("/bad")
async def bad():
# BUG: time.sleep blocks the event-loop thread. Every other coroutine on
# this worker (including /ping) waits for the full sleep.
time.sleep(SLEEP_S)
return {"handler": "async def + time.sleep", "slept": SLEEP_S}
@app.get("/ok-def")
def ok_def():
# FastAPI runs plain `def` path ops in a threadpool, so the loop stays free.
time.sleep(SLEEP_S)
return {"handler": "def + time.sleep", "slept": SLEEP_S}
@app.get("/ok-await")
async def ok_await():
# Correct async: await yields control back to the loop during the wait.
await asyncio.sleep(SLEEP_S)
return {"handler": "async def + await asyncio.sleep", "slept": SLEEP_S}
Start it with a single worker so there is exactly one event loop to freeze:
uvicorn app:app --host 127.0.0.1 --port 8765 --workers 1
The bench
For each path, fire 10 concurrent GETs and keep probing /ping every 50 ms until they finish:
# bench.py — wall time of N work requests + /ping latency during the burst
from __future__ import annotations
import asyncio
import statistics
import sys
import time
import httpx
N = 10
PING_EVERY_S = 0.05
ENDPOINTS = ("/bad", "/ok-def", "/ok-await")
async def run_case(client: httpx.AsyncClient, path: str) -> None:
ping_latencies: list[float] = []
stop = asyncio.Event()
async def pinger() -> None:
while not stop.is_set():
t0 = time.perf_counter()
try:
r = await client.get("/ping", timeout=10.0)
r.raise_for_status()
ping_latencies.append((time.perf_counter() - t0) * 1000)
except Exception as e: # noqa: BLE001
ping_latencies.append(float("nan"))
print(f" ping error: {e}", file=sys.stderr)
try:
await asyncio.wait_for(stop.wait(), timeout=PING_EVERY_S)
except asyncio.TimeoutError:
pass
probe = asyncio.create_task(pinger())
await asyncio.sleep(0.05)
t0 = time.perf_counter()
results = await asyncio.gather(
*[client.get(path, timeout=30.0) for _ in range(N)],
return_exceptions=True,
)
wall_ms = (time.perf_counter() - t0) * 1000
stop.set()
await probe
oks = sum(1 for r in results if isinstance(r, httpx.Response) and r.status_code == 200)
clean = [x for x in ping_latencies if x == x]
def pct(xs: list[float], p: float) -> float:
xs = sorted(xs)
i = min(len(xs) - 1, max(0, int(round((p / 100) * (len(xs) - 1)))))
return xs[i]
print(f"=== {path} ({N} concurrent, sleep=0.5s each) ===")
print(f"work wall_ms : {wall_ms:.1f}")
print(f"work ok : {oks}/{N}")
print(f"ping samples : {len(clean)}")
print(f"ping mean_ms : {statistics.mean(clean):.1f}")
print(f"ping p50_ms : {pct(clean, 50):.1f}")
print(f"ping p95_ms : {pct(clean, 95):.1f}")
print(f"ping max_ms : {max(clean):.1f}")
print()
async def main(base: str) -> None:
async with httpx.AsyncClient(base_url=base, limits=httpx.Limits(max_connections=50)) as client:
(await client.get("/ping")).raise_for_status()
for path in ENDPOINTS:
await run_case(client, path)
await asyncio.sleep(0.3)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8765"))
./run.sh starts uvicorn, waits for /ping, runs the bench, and tears the server down.
The run (unedited)
== 3.13.5 ; fastapi 0.162.0 ; uvicorn 0.54.0
== starting uvicorn app:app --host 127.0.0.1 --port 8765 --workers 1
base=http://127.0.0.1:8765
N=10 SLEEP=0.5s PING_EVERY=0.05s
=== /bad (10 concurrent, sleep=0.5s each) ===
work wall_ms : 5016.7
work ok/err : 10/0
ping samples : 11
ping mean_ms : 414.9
ping p50_ms : 450.7
ping p95_ms : 502.7
ping max_ms : 502.7
=== /ok-def (10 concurrent, sleep=0.5s each) ===
work wall_ms : 529.5
work ok/err : 10/0
ping samples : 11
ping mean_ms : 3.6
ping p50_ms : 1.8
ping p95_ms : 20.9
ping max_ms : 20.9
=== /ok-await (10 concurrent, sleep=0.5s each) ===
work wall_ms : 510.1
work ok/err : 10/0
ping samples : 11
ping mean_ms : 1.8
ping p50_ms : 1.9
ping p95_ms : 2.5
ping max_ms : 2.5
What that means in practice:
| Endpoint | Where the sleep runs | 10 × 0.5 s wall | /ping while they run |
|---|---|---|---|
/bad |
Event-loop thread (MainThread) |
5016.7 ms ≈ 10 × 0.5 s, serialized | p50 450.7 ms, max 502.7 ms |
/ok-def |
AnyIO / Starlette threadpool worker | 529.5 ms ≈ one sleep | p50 1.8 ms |
/ok-await |
Event loop, but yielded at await |
510.1 ms ≈ one sleep | p50 1.9 ms |
/bad is not "slow code". It is cooperative multitasking with nothing to cooperate on. The loop can only switch when a coroutine awaits something that yields. time.sleep never does, so every other task on that worker waits — including the next /bad and every /ping.
Why FastAPI does this
From the FastAPI docs: when you declare a path operation with plain def, it is run in an external threadpool that is then awaited, instead of being called directly (as it would block the server). When you declare it with async def, FastAPI awaits it on the loop. The choice is the signature alone. Sync Depends(...) get the same threadpool treatment; a sync helper you call inside an async def body does not.
That is why this looks fine in a single-request curl and falls over under concurrency or under a probe that shares the worker.
The other mistakes that look like /bad
time.sleep is the teaching tool. In a real service the same shape shows up as:
Call inside async def |
Why it blocks | Fix |
|---|---|---|
requests.get(...) / sync HTTP SDK |
Sync socket I/O on the loop thread | httpx.AsyncClient, or def, or await asyncio.to_thread(...) |
| Sync DB driver / ORM session | Sync I/O on the loop thread | Async driver, or def endpoint |
open(...).read() on a big file |
Sync disk I/O | await asyncio.to_thread(...), or def |
| Heavy CPU (hashing, image work) | Holds the GIL / loop | Process pool or a job queue; threads only help if the C code releases the GIL |
A helper two calls down still counts. FastAPI offloads sync def dependencies; it cannot see into your own function body.
def is not free either
The threadpool is bounded (AnyIO's default limiter is commonly 40 tokens, shared by every plain def endpoint and sync dependency on that worker). Once every slot is busy, new def requests wait for a thread with no loud error — latency just climbs. That is still better than freezing the loop, but it is a capacity limit, not infinite parallelism. For high-concurrency I/O, a real async stack scales further because idle waits are coroutines, not OS threads.
If the endpoint is mostly async and only one call is sync, keep async def and offload that call:
from starlette.concurrency import run_in_threadpool
import asyncio
@app.get("/mixed")
async def mixed(user_id: int):
# mostly async path… then one sync library:
profile = await run_in_threadpool(load_profile_sync, user_id)
# or: profile = await asyncio.to_thread(load_profile_sync, user_id)
return profile
run_in_threadpool shares AnyIO's limiter with plain def endpoints. asyncio.to_thread uses the loop's default executor (a separate pool). Both copy contextvars; a raw loop.run_in_executor call does not.
How to catch it before production
- Run under concurrency, not one curl. A blocking call looks fine alone.
- Keep a cheap
/ping(or your real health check) on the same worker and watch its latency while you load a suspect route. In this run,/pingp50 jumped from ~2 ms to 450.7 ms with no change to/pingitself. PYTHONASYNCIODEBUG=1(orasyncio.run(..., debug=True)) logs callbacks that hold the loop longer thanloop.slow_callback_duration(100 ms by default).- Adding
--workersspreads the stalls across more processes. It does not fix a blockingasync def; it multiplies memory and connection pools. Fix the handler first.
Checklist
- Inside
async def, every wait is anawaiton something that yields (async DB, async HTTP,asyncio.sleep). - Sync libraries (
requests, sync ORM, sync SDKs) live in a plaindefendpoint, or behindawait run_in_threadpool/await asyncio.to_thread. - Unsure which to pick? Use
def. - Do not put
time.sleepinasync def. Useawait asyncio.sleep. - Load-test with concurrency and a concurrent health/ping probe on the same worker.
- Remember the threadpool is bounded; saturating it queues
defwork instead of freezing the loop. - CPU-bound work needs a process pool or a queue, not
async defwishful thinking.
Related posts
- Retrying fetch in Node.js the Right Way: Backoff, Retry-After, Deadlines — another place where a missing deadline turns a "retry" into a multi-second stall for the caller.
- Idempotency Keys in Spring Boot: Making POST Safe to Retry — safe retries on the server side when clients time out and fire again.
- Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry — a different runtime, same lesson: moving the wait into the wrong pool changes which requests fail.
Sources
- FastAPI — Concurrency and async / await
- Starlette
run_in_threadpool/ AnyIO thread limiter (what plaindefpath operations use) - asyncio event loop: cooperative scheduling; switches only at
awaitpoints that yield
Written by Gowtham M. Code tested on Python 3.13.5 / FastAPI 0.162.0 / uvicorn 0.54.0, 6 Nov 2026.
Comments
Join the discussionNo comments yet. Be the first to comment.
Backend & APIs
Practical, tested backend engineering: Spring Boot, REST APIs, databases and the bugs that only show up under real traffic.




