FastAPI async def vs def: Performance and When to Use Each

Key takeaways:

  • async def wins only when the handler awaits async I/O and can yield the loop.
  • def handlers run in a thread pool, so synchronous blocking is safe there.
  • The worst case is blocking code inside async def, which freezes the loop for everyone.
  • Offload unavoidable blocking calls from async def with anyio.to_thread.run_sync.
  • Confirm your choice by load testing under concurrency, not with a single request.

This guide makes the choice concrete; the underlying model is in Async Correctness and Concurrency.

The Problem This Solves

Developers often assume async def is always faster and convert every route, then call a synchronous database driver inside it and wonder why throughput collapsed. The keyword does not make code asynchronous; awaiting async I/O does. Choosing wrong in either direction costs throughput.

Prerequisites

  • FastAPI on Uvicorn.
  • A load-testing tool such as hey or wrk to measure under concurrency.

Step-by-Step Implementation

1. Async I/O → async def

import httpx


@app.get("/profile")
async def profile() -> dict:
    # Awaiting async I/O lets the loop serve other requests during the wait.
    async with httpx.AsyncClient() as client:
        return (await client.get("https://api.example.com/me", timeout=5)).json()

2. Blocking work → def

@app.get("/legacy-report")
def legacy_report() -> dict:
    # A sync driver here is fine: FastAPI runs def handlers in a thread pool.
    rows = sync_db.query("SELECT ...")
    return {"rows": rows}

3. Blocking inside async def → offload

import anyio


@app.get("/digest")
async def digest() -> dict:
    # Must call blocking code from async def → offload so the loop stays free.
    value = await anyio.to_thread.run_sync(blocking_hash)
    return {"digest": value}

Edge Cases and Gotchas

  • Hidden blocking. An async def that calls a library which blocks internally is as harmful as an explicit blocking call; profile it.
  • Thread-pool saturation. Many slow def handlers exhaust the thread pool and reintroduce queuing; move heavy work to a queue.
  • CPU-bound work. Threads do not help CPU-bound work because of the GIL; use a process pool.

Verification

Load test both styles under concurrency and compare throughput:

# A correctly async endpoint sustains high concurrency; a blocked one serializes.
hey -z 10s -c 50 http://localhost:8000/profile

If latency scales linearly with concurrency on an async def endpoint, it is blocking the loop somewhere.