Python for JS Devs · FastAPI

The async blocking-I/O trap

The #1 FastAPI mistake — and the one your JS event-loop instincts let you understand faster than most Python devs.

Lesson 17Time ~14 min Win Decide def vs async def correctlyPrereq Lesson 10

Recall Lesson 10: one event loop, one thread, and blocking work freezes it. This lesson is that law applied to FastAPI — and it's exactly the bug in the AI-generated code you started with.

1 · The bug you already wrote (well, the AI did)

@router.post("/register")
async def register_user(user: RegisterUser, db: Session = Depends(get_db)):
    if db.query(User).filter(...).first():   # ⚠️ BLOCKING call…
        ...                                # …inside an async def
Why this is wrong async def tells FastAPI "this runs on the event loop." But db.query(...) is a synchronous, blocking DB call. While it waits on the database, it blocks the entire loop — every other request is frozen until it returns. You get the worst of both worlds: async ceremony, zero concurrency, and worse throughput under load.

2 · The rule (memorize this)

Your handler does…Declare it…Because
awaits async libs only (httpx.AsyncClient, async DB)async defCooperates with the loop
Blocking/sync I/O (sync SQLAlchemy, requests, file I/O)defFastAPI runs it in a threadpool
Nothing I/O-bound, pure quick logiceitherDoesn't matter much
The magic you get for free Here's the twist that has no Node equivalent: if you declare a route as plain def (not async), FastAPI automatically runs it in a threadpool, so a blocking DB call there does not freeze the loop. So the fix for the buggy code is often just: drop the async. Sync route + sync DB = correct. Async route + async DB = correct. The trap is only async route + sync blocking call.

3 · The two correct versions

Correct: sync route + sync DB
@router.post("/register")
def register(user: RegisterUser,
             db: Session = Depends(get_db)):
    # runs in threadpool; loop stays free
    ...
Correct: async route + async DB
@router.post("/register")
async def register(user: RegisterUser,
                   db: AsyncSession = Depends(get_db)):
    await db.execute(...)   # awaited, non-blocking
Also never do this in async Inside an async def, don't call time.sleep() (use await asyncio.sleep()), don't use the sync requests library (use httpx.AsyncClient), and push heavy CPU work to run_in_executor / a worker. Same discipline as never blocking Node's loop.

🎯 Your win

Write two routes: @app.get("/block") as async def that calls time.sleep(5), and @app.get("/ok") that returns instantly. Hit /block then immediately /ok in another tab — /ok is frozen for 5s. Now change /block to plain def and repeat — /ok responds instantly. Feeling the loop freeze and unfreeze is the win, and it locks in the rule forever.

Check yourself

Q1. A sync blocking DB call inside an async def route will:
Async routes run on the loop; a blocking call there freezes every other request.
Q2. A plain def route with blocking I/O is handled by FastAPI via:
FastAPI offloads sync def routes to a threadpool, keeping the loop free.
Q3. Inside async code, pausing should be done with:
await asyncio.sleep() yields to the loop; time.sleep() blocks it.
Primary source FastAPI — Concurrency and async / await. tiangolo's own deep, honest explanation of exactly this.
💬 Ask your teacher: how big is the threadpool, and when does the sync-route approach stop scaling? Great production question.