async def vs def and the threadpool

FastAPI · Advanced · 16 min

Choosing wrong can silently serialize a whole worker; it's the most common FastAPI performance bug.

Each worker process runs one event loop (via Starlette and AnyIO). What happens to your function depends on how you declare it:

  • async def runs on the event loop. While it waits at an await, the loop serves other requests. But a blocking call inside it, such as time.sleep, requests.get, a sync database driver or heavy computation, freezes the loop, and every request on that worker waits.
  • Plain def runs in a threadpool via run_in_threadpool, so blocking I/O only ties up one thread. The same rules apply to dependencies.

The decision rule:

  • The code awaits async libraries (httpx.AsyncClient, asyncpg, SQLAlchemy's asyncio extension, redis.asyncio): use async def.
  • The code calls blocking libraries: use def.
  • Trivial code with no I/O: either works, and async def skips the thread hop.

The threadpool is AnyIO's default limiter, 40 threads per process. Under load with slow sync calls, requests queue for a free thread even though the CPU is idle. You can raise the limit at startup (anyio.to_thread.current_default_thread_limiter().total_tokens = 100), but threads cost memory and add contention; moving hot paths to async drivers usually scales further.

When an async endpoint must call one blocking function, push it off the loop with await run_in_threadpool(fn, arg) from fastapi.concurrency, or await anyio.to_thread.run_sync(fn, arg). CPU-heavy work such as image processing doesn't belong in the web process at all: threads don't run Python bytecode in parallel under the GIL, so use a process pool or a task queue.

A quick diagnostic: if /health gets slow whenever one heavy endpoint is busy, something is blocking the event loop.

Example

import asyncio, time
import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/bad")
async def bad():
    time.sleep(0.5)            # blocks the event loop
    return "bad"

@app.get("/sync")
def sync():
    time.sleep(0.5)            # runs in the threadpool
    return "ok"

@app.get("/async")
async def non_blocking():
    await asyncio.sleep(0.5)   # yields to the loop while waiting
    return "ok"

async def main():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
        for path in ("/bad", "/sync", "/async"):
            start = time.perf_counter()
            await asyncio.gather(*(c.get(path) for _ in range(5)))
            print(path, round(time.perf_counter() - start, 1))

asyncio.run(main())   # /bad 2.5   /sync 0.5   /async 0.5

Remember

  • async def runs on the event loop: never block inside it
  • def runs in a 40-thread pool: safe for blocking libraries
  • Use run_in_threadpool to call one blocking function from async code
  • CPU-bound work belongs in processes or a task queue, not the web worker

Watch out for

  • Calling requests, time.sleep or a sync DB driver inside async def.
  • Making everything async def 'for speed' while still using blocking libraries.

Check yourself

  1. An async def endpoint calls requests.get(), which takes 2 s. Ten requests arrive at once on one worker. What happens?
  2. What's the smallest correct fix for that endpoint?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/async/