# FastAPI `def` vs `async def`: When Your Endpoint Blocks Everything

# 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:

```text
/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 def` means: this coroutine runs on the event-loop thread. FastAPI trusts you not to block. There is no inspection of the body.
- Plain `def` means: 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 inside `async def` freezes every other request on that worker, including health checks.
- Prefer `await asyncio.sleep` (or a real async library) inside `async def`. Prefer plain `def` when the library is sync. Prefer `await run_in_threadpool(...)` / `await asyncio.to_thread(...)` when the endpoint is mostly async but one call is sync.
- Unsure? Use `def`. A misjudged `async def` fails under concurrency in a way that is hard to see from a single-request test.

![Why async def + time.sleep stalls the event loop](https://cdn.hashnode.com/uploads/covers/658c3a46e339366b1a80b9f3/925516aa-6b76-4d1a-b21b-5e4b8f4cfe68.png align="center")

## 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](https://fastapi.tiangolo.com/async/) | 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](https://blog.oneenv.io/articles/fastapi-blocking-calls-in-async-endpoints-def-vs-async-def) | 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](https://laneparton.com/posts/async-vs-sync-in-fastapi/) | Strong production framing (probes fail, pods restart); simple rulebook | Conceptual; no numbers |
| [fastapi/fastapi discussion #4358](https://github.com/fastapi/fastapi/discussions/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](https://fastapi-patterns.com/async-background-tasks-observability/async-correctness-concurrency/fastapi-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:

```python
# 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:

```bash
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:

```python
# 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)

```text
== 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](https://fastapi.tiangolo.com/async/#path-operation-functions): 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:

```python
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, `/ping` p50 jumped from ~2 ms to 450.7 ms with no change to `/ping` itself.
- `PYTHONASYNCIODEBUG=1` (or `asyncio.run(..., debug=True)`) logs callbacks that hold the loop longer than `loop.slow_callback_duration` (100 ms by default).
- Adding `--workers` spreads the stalls across more processes. It does not fix a blocking `async def`; it multiplies memory and connection pools. Fix the handler first.

## Checklist

- [ ] Inside `async def`, every wait is an `await` on something that yields (async DB, async HTTP, `asyncio.sleep`).
- [ ] Sync libraries (`requests`, sync ORM, sync SDKs) live in a plain `def` endpoint, or behind `await run_in_threadpool` / `await asyncio.to_thread`.
- [ ] Unsure which to pick? Use `def`.
- [ ] Do not put `time.sleep` in `async def`. Use `await asyncio.sleep`.
- [ ] Load-test with concurrency **and** a concurrent health/ping probe on the same worker.
- [ ] Remember the threadpool is bounded; saturating it queues `def` work instead of freezing the loop.
- [ ] CPU-bound work needs a process pool or a queue, not `async def` wishful thinking.

## Related posts

- [Retrying fetch in Node.js the Right Way: Backoff, Retry-After, Deadlines](https://gowthammuthuvel.hashnode.dev/node-fetch-retry-backoff-retry-after-deadline) — 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](https://gowthammuthuvel.hashnode.dev/idempotency-keys-spring-boot) — safe retries on the server side when clients time out and fire again.
- [Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry](https://gowthammuthuvel.hashnode.dev/spring-boot-virtual-threads-hikaricp-pool-exhausted) — a different runtime, same lesson: moving the wait into the wrong pool changes which requests fail.

## Sources

- [FastAPI — Concurrency and async / await](https://fastapi.tiangolo.com/async/)
- Starlette `run_in_threadpool` / AnyIO thread limiter (what plain `def` path operations use)
- asyncio event loop: cooperative scheduling; switches only at `await` points that yield

Written by Gowtham M. Code tested on Python 3.13.5 / FastAPI 0.162.0 / uvicorn 0.54.0, 6 Nov 2026.
