async vs sync Routes

intermediate fastapi async concurrency

FastAPI lets us write route handlers as either def or async def. Both work, both return JSON, but under the hood they run on completely different machinery. Picking the wrong one tanks throughput.

In simple language: async def runs on the event loop (one thread, juggles many requests). Plain def runs on a thread pool (FastAPI offloads it so the event loop stays free). So FastAPI is smart enough to not block — but only if we don’t lie to it.

The rule

  • Use async def when our code uses await (async DB driver, httpx.AsyncClient, asyncio.sleep).
  • Use plain def when our code is sync (requests, psycopg2, time.sleep, CPU work).
  • Never mix: don’t call blocking code inside async def without offloading it.
async def
Single event loop thread
req1 ─await─┐
req2 ─await─┼─► loop
req3 ─await─┘
100s of concurrent reqs OK
def
Thread pool (default 40)
req1 ─► thread1
req2 ─► thread2
req3 ─► thread3
Capped by pool size

The killer pitfall

A blocking call inside async def freezes the entire event loop. Every other request in flight stalls. This is the single most common FastAPI perf bug.

import time
import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

# BAD — blocks the event loop, every other request waits
@app.get("/bad")
async def bad():
    time.sleep(2)              # sync sleep inside async
    return {"status": "done"}

# GOOD — async sleep yields back to the loop
@app.get("/good")
async def good():
    await asyncio.sleep(2)
    return {"status": "done"}

# ALSO GOOD — sync function, FastAPI runs it on a thread
@app.get("/sync-ok")
def sync_ok():
    time.sleep(2)
    return {"status": "done"}

Real example: calling an external API

import httpx
import requests
from fastapi import FastAPI

app = FastAPI()

# async route + async client = correct
@app.get("/weather/{city}")
async def weather(city: str):
    async with httpx.AsyncClient() as client:
        r = await client.get(f"https://api.weather.com/{city}")
    return r.json()

# sync route + sync client = also correct, just less scalable
@app.get("/weather-sync/{city}")
def weather_sync(city: str):
    r = requests.get(f"https://api.weather.com/{city}")
    return r.json()

Offloading CPU work

If we have a CPU-heavy function and want to call it from an async route, use run_in_threadpool (or a process pool for true parallelism, since the GIL still bites).

from fastapi.concurrency import run_in_threadpool

def heavy_pdf_parse(data: bytes) -> str:
    # expensive, blocking, CPU-bound
    return parse(data)

@app.post("/upload")
async def upload(file: bytes):
    text = await run_in_threadpool(heavy_pdf_parse, file)
    return {"text": text}

Interview cheat sheet

  • “Why is my FastAPI app slow?” → Look for blocking calls inside async def.
  • “What’s the difference?” → Event loop vs thread pool, both non-blocking from FastAPI’s perspective.
  • “Should I make everything async?” → Only if the libraries we use are async. A sync DB call in an async route is worse than a fully sync route.