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 defwhen our code usesawait(async DB driver,httpx.AsyncClient,asyncio.sleep). - Use plain
defwhen our code is sync (requests, psycopg2, time.sleep, CPU work). - Never mix: don’t call blocking code inside
async defwithout offloading it.
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.