Async routes
async def vs. plain def routes
A route can be declared either way:
@app.get("/sync-agent")
def get_agent_sync():
return {"name": "research_agent"}
@app.get("/async-agent")
async def get_agent_async():
return {"name": "research_agent"}Both work, and for a route like this — no actual waiting involved — there’s no real difference. The distinction only matters once a route actually does I/O-bound waiting: calling an LLM API, another service, or awaiting anything else inside the route function.
Where it actually matters
import asyncio
async def call_llm_api(prompt: str) -> str:
await asyncio.sleep(7) # standing in for a real network call
return f"response to: {prompt}"
@app.post("/generate")
async def generate(prompt: str):
result = await call_llm_api(prompt)
return {"result": result}click Run to see this pane's outputasync def generate can await call_llm_api(...) directly — exactly
the coroutine-calling-coroutine pattern from the async lesson.
While this one request is waiting on call_llm_api, FastAPI’s event
loop is free to handle other incoming requests concurrently — the
entire reason this course built up asyncio in the first place, now
paying off directly inside route handling.
The trap: a blocking call inside async def
This is
the exact poisoning behavior from the async lesson,
now in a context where it’s easy to hit by accident — a route declared
async def that calls a genuinely blocking function (a synchronous
database driver, time.sleep(), a non-async HTTP library) blocks the
entire server, not just that one request:
import time
@app.post("/generate-blocking")
async def generate_blocking(prompt: str):
time.sleep(7) # blocking — freezes every other request too
return {"result": f"response to: {prompt}"}Two clients, hitting this server at the same moment — one calling the
broken route, the other just checking /health, something with
nothing to do with /generate-blocking at all:
click Run to see this pane's outputclick Run to see this pane's output/health does nothing and depends on nothing — it still took the full
seven seconds, because
one blocking call poisons the entire event loop,
the same way it poisoned an entire gather() in the async lesson. The
rule from that lesson applies unchanged: never call a blocking function
from inside async def code.
The fix, if a route genuinely needs blocking code
If a route needs to call something blocking that has no async
alternative, the fix is simply not declaring it async def — FastAPI
automatically runs a plain def route in a separate thread pool,
rather than directly on the event loop:
@app.post("/generate-safe")
def generate_safe(prompt: str):
time.sleep(7) # blocking, but this route isn't async def —
return {"result": f"response to: {prompt}"} # FastAPI runs it in a thread pool insteadSame two clients, same moment, only this route changed:
click Run to see this pane's outputclick Run to see this pane's output/health comes straight back this time — the blocking work still takes
its full seven seconds, but off on its own thread, not on the shared event
loop /health needed to run on. This route still isn’t concurrent
with itself the way await-ing a real async call would be, but it no
longer blocks the entire server the way the broken async def
version did.
The practical rule: declare a route async def only when it
actually awaits something — an async database driver, an async HTTP
client, another coroutine. If a route’s logic is entirely synchronous
(plain, blocking calls, no await anywhere in its body), leave it a
plain def and let FastAPI’s thread pool handle it safely, rather than
declaring async def out of habit and accidentally introducing the
poisoning trap.
For a route with no actual waiting involved (just returning a value immediately), does it matter whether it's declared async def or plain def?