Why Your FastAPI 'async' Endpoints Run in Serial: The Blocking Event Loop Trap
You wrote your FastAPI endpoint async def because that is what the tutorials do. You fire ten requests at it expecting them to run concurrently. Instead they finish one after another, each waiting for the last, and total time is ten times a single request. The app is "async" and behaving exactly like the blocking synchronous server you were trying to escape.
The thesis, stated plainly so you can stop guessing: async def does not make anything asynchronous. It marks a function as a coroutine that runs on a single event loop thread. If that function calls something that blocks, time.sleep(), requests.get(), a synchronous database driver, a plain file read, it blocks the one thread every other request is waiting to use. The event loop cannot switch to another task, because your code never gave it the chance. You did not get concurrency. You got a slower version of a normal server, with a fancier keyword.
This is the single most common performance bug in FastAPI, and the usual fixes make it worse. Adding uvicorn workers hides it under light load. Wrapping more code in async def is precisely the wrong direction. The fix is to understand which of two execution paths your endpoint is on, and put your blocking code on the right one.
A blocking call inside async def freezes the whole event loop, serialising every concurrent request. Two correct fixes:
- If a route contains unavoidable blocking code (a sync DB driver,
requests, heavy CPU), declare itdef, notasync def. FastAPI runs plaindefroutes in a threadpool, so they do not block the loop. - If you want real
async def, every I/O call inside it must itself be async:httpx.AsyncClientnotrequests,asyncpgor async SQLAlchemy not a sync driver,await asyncio.sleep()nottime.sleep().
For a blocking call you cannot avoid inside an async def, offload it with await run_in_threadpool(fn, ...) or await asyncio.to_thread(fn, ...). Adding uvicorn workers does not fix this; each worker still has exactly one event loop.
The symptom: FastAPI runs requests in serial instead of parallel
Here is the trap in its simplest reproducible form. This endpoint looks async and is completely blocking:
class="language-python"
Fire five requests at once and time them:
class="language-bash"
class="language-text"
Ten seconds for five two-second requests. They ran strictly one after another. time.sleep(2) is a synchronous C call that holds the event loop thread for its full duration; while it sleeps, the loop cannot start, resume, or even accept another coroutine. Every other request queues behind it. This is not FastAPI being slow. This is you handing FastAPI blocking code and labelling it async.
Why this happens: one event loop, one thread
An ASGI server like uvicorn runs a single event loop per worker process, on a single thread. Concurrency on that loop is cooperative: a coroutine runs until it hits an await on something that is genuinely waiting (a socket, a timer), at which point it yields control back to the loop, which runs another ready task. The magic word is not async, it is await on a non-blocking operation.
time.sleep(), requests.get(), psycopg2, open().read() on a slow disk, and any CPU-heavy loop never yield. They occupy the thread. So inside an async def, they are strictly worse than useless: they block the exact thread that is supposed to be juggling every other request.
FastAPI gives you a second path, and this is the part people miss. When you declare a route with plain def instead of async def, FastAPI does not run it on the event loop at all. It runs it in an external threadpool (via AnyIO) and awaits the result. Each blocking def route gets its own thread, so they run in parallel and the loop stays free. The threadpool has a default size of 40 threads, which is your real concurrency ceiling for sync routes.
The fix, in numbered steps
Step 1: If the route is blocking, use def, not async def
The smallest correct change to the broken example is to delete one keyword:
class="language-python"
Re-run the five-request test:
class="language-text"
Five two-second requests now finish in two seconds, because each ran on its own threadpool thread. If your route uses a synchronous database driver, a sync SDK, requests, or does CPU work, and you cannot easily make it async, this is the right answer. Do not force it to be async def.
Step 2: If you want async def, make every I/O call async
The other correct path is to keep async def and replace every blocking call with an awaitable, non-blocking equivalent:
class="language-python"
For the database, that means an async driver: asyncpg, or SQLAlchemy's async engine with create_async_engine and AsyncSession. A single sync session.execute() inside async def reintroduces the exact bug you are fixing. And when you do move to an async pool, size it deliberately, because pool exhaustion is the next wall you hit, covered in QueuePool limit reached under FastAPI load.
Step 3: For an unavoidable blocking call inside async def, offload it
Sometimes you are in an async def (because most of it is genuinely async) and you have one stubborn blocking call, a library with no async version, or a CPU-bound function. Push just that call into a thread:
class="language-python"
Both hand the blocking function to a worker thread and await its result, so the event loop stays free. run_in_threadpool uses the same AnyIO threadpool FastAPI uses for def routes and propagates context variables; asyncio.to_thread is the plain standard-library version. Either is correct.
One caveat: threads do not help CPU-bound work scale past one core, because of the GIL. For heavy CPU work, offload to a process pool or a task queue like Celery instead of a thread.
Verification: prove it is actually concurrent
The five-parallel-curls test above is the fastest proof. For a real load picture, use a benchmarking tool and watch the difference between the broken and fixed versions:
class="language-bash"
On the blocking async def version, requests-per-second stays near 1 / request_time no matter how high you set concurrency, because everything serialises. On the fixed version, throughput scales with concurrency until you hit the threadpool size (40) or your actual resource limit. If RPS does not move when you raise -c, you are still blocking the loop somewhere.
What people get wrong
Adding uvicorn workers to "fix concurrency." Workers are separate processes, each with its own single event loop. Four workers let four blocking requests run at once instead of one, which papers over the problem at low traffic and collapses the moment concurrent load exceeds your worker count. Workers are for using multiple CPU cores, not for rescuing a blocked event loop. Fix the blocking first, then scale workers for cores.
Making everything async def because it sounds faster. An async def full of synchronous calls is the worst of both worlds: it runs on the loop (so it blocks everything) and gets none of the threadpool parallelism a plain def route would have had. If the body is blocking, def is not a compromise, it is the correct choice.
Reaching for asyncio.create_task or gather to "parallelise" blocking calls. Scheduling more coroutines on a loop that is already blocked does nothing; they cannot run until the blocking call returns. gather only helps when the tasks actually await non-blocking I/O.
Using async def with a synchronous ORM session. This is the sneakiest version because it looks fully async. A plain SQLAlchemy Session.execute() blocks the loop just like time.sleep(). Either use the async engine or make the route def.
When it is still serial
- Throughput caps at exactly 40. That is the default threadpool size for
defroutes andrun_in_threadpool. For a high-concurrency blocking workload, either move to truly async drivers (the loop handles far more than 40 concurrent I/O waits) or raise the AnyIO threadpool limit deliberately, knowing each thread costs memory. - A dependency is blocking. The bug can hide in a
Depends()function, not the route. Anasync defdependency that calls a sync SDK blocks the loop for every request that uses it. Audit dependencies the same way you audit routes. - Middleware is blocking. Custom middleware runs on the event loop. A blocking call there serialises everything regardless of how clean your routes are.
- CPU-bound, not I/O-bound. Threads will not scale CPU work past one core because of the GIL. If your "slow" endpoint is doing computation, move it to a process pool or a background worker. Threadpool offloading only helps with I/O and blocking-but-idle calls.
- Startup import fails and you are testing the wrong process. If the app will not even load, the concurrency question is moot; sort out error loading ASGI app first.
The mental model that ends this class of bug: on the event loop, code either yields the thread or hogs it. async/await is only concurrency when the awaited thing genuinely waits. When in doubt, ask "does this line release the thread?" If the answer is no, it belongs in a def route or a thread, never bare inside async def.
Frequently asked questions
- Why does FastAPI run my async requests one at a time instead of in parallel?
- Because there is a blocking call inside your async def. An ASGI server runs one event loop on one thread, and a synchronous call like time.sleep(), requests.get() or a sync database driver holds that thread for its whole duration, so no other request can run until it returns. async def is not the cause of concurrency; awaiting non-blocking I/O is. A blocking call inside async def serialises everything.
- Should I use def or async def for my FastAPI endpoint?
- Use async def only if every I/O call inside it is awaitable and non-blocking (httpx, asyncpg, async SQLAlchemy, asyncio.sleep). If the route contains any blocking call you cannot easily make async, use plain def instead: FastAPI runs def routes in a threadpool, so they run in parallel without blocking the event loop. An async def full of synchronous calls is the worst option.
- Will adding more uvicorn workers fix serial request processing?
- No. Each worker is a separate process with its own single event loop, so more workers only let a few blocking requests run at once and hide the problem under light load. Under real concurrency it collapses again. Workers exist to use multiple CPU cores, not to rescue a blocked event loop. Fix the blocking code first, then scale workers for cores.
- How do I run a blocking function inside an async def route?
- Offload it to a thread so the event loop stays free. Use await run_in_threadpool(fn, args) from fastapi.concurrency, or await asyncio.to_thread(fn, args) from the standard library. Both hand the blocking call to a worker thread and await its result. Note this only helps I/O and blocking-but-idle calls; CPU-bound work will not scale past one core because of the GIL, so use a process pool or task queue for that.