Skip to main content

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.

Updated 12 min readMarkdown
Part of seriesBackend & APIs
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:

/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

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, /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.

Sources

  • FastAPI — Concurrency and async / await
  • 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.

No comments yet. Be the first to comment.