Backend / Web frameworks / FastAPI / 10_fastapi_async.md

FastAPI and the event loop

Updated 5 interview angles 3 min read source
On this page4
  1. def and async def are routed differently
  2. Depends is a graph, resolved per request
  3. Where the concurrency actually comes from
  4. Interview angle

FastAPI and the event loop

FastAPI is an ASGI application. It does not own the event loop — Uvicorn does — and your handlers are coroutines scheduled on it. Everything that surprises people about FastAPI performance follows from that one fact.

def and async def are routed differently

python
@app.get("/a")
async def a():
    # runs ON the event loop
    return await db.fetch(...)

@app.get("/b")
def b():
    # runs in a THREAD POOL
    return blocking_db.fetch(...)

FastAPI inspects the handler. A coroutine function runs on the loop; a plain function is handed to a worker thread so it cannot stall everything else.

The dangerous combination is the third one — blocking code inside async def, which nothing rescues:

python
@app.get("/c")
async def c():
    # stalls every request
    return requests.get(url).json()

One slow call there freezes the whole worker, including handlers that touch nothing. The symptom is p99 rising on every endpoint at once, which is the same signature as Debugging and introspecting async code.

Handler Blocking call inside Result
def fine thread pool absorbs it
async def never blocks all requests
async def await async lib correct

Gotcha: the thread pool is bounded (40 by default via AnyIO). Enough concurrent def handlers and requests queue for a thread — latency rises with no CPU pressure and no slow query to point at.

If you must call blocking code from an async handler, hand it to a thread explicitly:

python
row = await anyio.to_thread.run_sync(blocking_db.fetch, key)

Depends is a graph, resolved per request

python
async def get_session() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as s:
        # teardown after the response
        yield s

async def current_user(
    s: Annotated[AsyncSession, Depends(get_session)],
    token: Annotated[str, Depends(oauth2)],
) -> User:
    return await s.get(User, decode(token).sub)

@app.get("/me")
async def me(user: Annotated[User, Depends(current_user)]):
    return user

Two properties worth naming. Dependencies composecurrent_user requests get_session and FastAPI resolves the graph. And a dependency requested twice in one request is called once, its result cached for that request, so get_session above yields the same session to everything downstream.

That caching is why Depends is the right place for a database session and the wrong place for anything that must run twice.

python
# Opt out when you genuinely want a fresh call.
Depends(make_nonce, use_cache=False)

Dependencies obey the same def/async def rule as handlers, so a synchronous dependency also goes to the thread pool.

Where the concurrency actually comes from

Async does not make one request faster. It makes a waiting request cheap, so one worker serves many. The corollary: it buys nothing for CPU-bound work — that needs more processes, not more coroutines.

Inside a single handler, concurrency is explicit:

python
async with asyncio.TaskGroup() as tg:
    a = tg.create_task(prices.get(sku))
    b = tg.create_task(stock.get(sku))
return {"price": a.result(), "stock": b.result()}

Total latency is the slower call rather than the sum, and a failure in either cancels the other. See TaskGroup and Structured Concurrency.

Interview angle 5

  • “What happens if you use a sync database driver in an async def route?” - it blocks the event loop for the duration, stalling every other request in that worker. Either use an async driver, or declare the route as plain def so FastAPI runs it in the threadpool.
  • “Is the threadpool unlimited?” - no, it is bounded (40 by default). Many concurrent blocking routes queue for a thread, and that queueing shows up as latency with no obvious CPU or database pressure.
  • “How do you run concurrent calls inside one handler?” - asyncio.TaskGroup with per-call timeouts, so total latency is the slowest call rather than the sum and one failure cancels the rest.
  • “What does Depends actually do?” - builds a dependency graph per request, resolves it, and caches each dependency’s result for the duration of that request. That per-request caching is why one database session is shared by everything downstream.
  • “Does async make the app faster?” - it makes waiting cheap, so one worker handles many concurrent I/O-bound requests. A single request is no faster, and CPU-bound work gets nothing at all — that needs more processes.