How the asyncio event loop runs a FastAPI worker
Each Uvicorn worker process runs one asyncio event loop on one thread. Every async def endpoint becomes a coroutine scheduled on that loop. The loop is cooperative. It switches between tasks only when the running coroutine reaches an await on something that yields, such as a socket read, a database driver call or asyncio.sleep. There is no preemption: the loop cannot interrupt a coroutine that hasn't awaited.
This is why blocking code is so damaging here. A time.sleep(2), a synchronous requests.get, or a 300 ms JSON transform holds the only thread for its full duration. During that time the loop can't accept connections, read request bodies, send responses or resume any other coroutine. Every in-flight request on that process stalls, including health checks. A single slow call adds latency to all concurrent requests.
Plain def endpoints and sync dependencies don't run on the loop. FastAPI hands them to a bounded thread pool through AnyIO and awaits the result, so the loop stays free:
one worker process
+----------------------------------------------+
| event loop thread (single) |
| async def /a --await--> async def /b --... |
| ^ switches only at await points |
| | dispatch & await result |
| +-------------------------------------+ |
| | threadpool (bounded) | |
| | def /reports sync Depends(...) | |
| | def /legacy ... | |
| +-------------------------------------+ |
+----------------------------------------------+
The trap is the boundary between the two. If you mark a function async def, FastAPI trusts you to never block inside it.
Uvicorn worker process (one of N)
+--------------------------------------------------------------------------+
| EVENT LOOP THREAD (single thread, cooperative, no preemption) |
| |
| time ----> |
| /a [run]--await db-->.................................[resume] |
| /b [run]--await-->..................[resume] |
| /c [ time.sleep(2) ] |
| |<-loop frozen->| |
| no await inside /c: /a, /b, new connections, health checks all wait |
| |
| | dispatch to thread (AnyIO) ^ await result (loop free) |
| v | |
| +------------------------------------------------------------------+ |
| | THREADPOOL (bounded; def endpoints + sync dependencies) | |
| | [t1] def /reports [t2] sync Depends(get_db) | |
| | [t3] def /legacy [tN] full? request waits | |
| +------------------------------------------------------------------+ |
| |
+--------------------------------------------------------------------------+
One Uvicorn worker: async def endpoints share a single event loop thread that switches only at await points, so a blocking call in /c freezes /a, /b and new connections, while plain def endpoints and sync dependencies run in a bounded AnyIO threadpool whose results the loop awaits.
Sync vs async def: where FastAPI actually runs your code
FastAPI makes the scheduling decision from the function signature alone. When a path operation is declared with plain def, FastAPI hands it to Starlette's run_in_threadpool, which runs it on a worker thread from a bounded pool while the event loop keeps serving other requests. Sync dependencies get the same treatment. When the function is declared with async def, FastAPI awaits it directly on the loop thread. No inspection of the body happens, so nothing protects you if that body blocks.
Consider two endpoints that do identical work: each calls time.sleep(1) and returns. The first is declared async def. The second is declared def.
async def with time.sleep(1) |
def with time.sleep(1) |
|
|---|---|---|
| Where it runs | Event loop thread | Threadpool worker thread |
| Effect on other requests | All requests on that worker stall for one second | Other requests continue on the loop |
| Ten concurrent calls | Run one after another, about ten seconds total | Run in parallel, about one second total, if the pool has free threads |
| Cost per in-flight request | A coroutine | An OS thread held for the full duration |
The def version is the safer default for any handler that calls blocking libraries, such as synchronous database drivers, requests, or SDKs that perform network I/O. A mistake in a def handler slows that one request. A mistake in an async def handler slows every request the worker is handling.
The def version still has a cost. Each request occupies a thread from start to finish, including time spent waiting on the network. Once the pool is exhausted, new requests queue behind it, and that queueing is easy to mistake for a slow downstream service. async def can hold thousands of idle waits for little memory, but it only delivers that benefit when every call inside the function actually yields to the loop.
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/blocking-async")
async def blocking_async():
# Runs directly on the event loop thread.
# time.sleep blocks that thread, so every other request
# handled by this worker stalls for the full second.
time.sleep(1)
return {"handler": "async def", "slept": 1}
@app.get("/blocking-sync")
def blocking_sync():
# FastAPI runs plain def handlers in a threadpool worker.
# The loop stays free to serve other requests, but this
# request holds one pool thread for the entire second.
time.sleep(1)
return {"handler": "def", "slept": 1}
# Try it with a single worker process:
# uvicorn main:app --workers 1
#
# Then fire ten concurrent requests at each endpoint, e.g.:
# seq 10 | xargs -P10 -I{} curl -s localhost:8000/blocking-async
# seq 10 | xargs -P10 -I{} curl -s localhost:8000/blocking-sync
#
# Expect roughly ten seconds total for /blocking-async (serialized)
# and roughly one second for /blocking-sync, as long as the
# threadpool has free threads.
Two endpoints doing the same blocking work: the async def version serializes requests on the event loop, while the def version runs each call on a threadpool thread that it holds for the full duration.
Hidden blocking calls that slip into async endpoints
A blocking call looks like any other function call. Nothing in the syntax marks requests.get() or a synchronous session.execute() as dangerous. The only signal is a missing await. Any call that doesn't yield control back to the event loop holds it for its full duration, and every other coroutine on that worker waits.
| Offender | Blocking type | Usual fix |
|---|---|---|
requests and other sync HTTP clients |
I/O | Async HTTP client, or offload to a thread |
| Sync database drivers and ORM sessions | I/O | Async driver and session, or a def endpoint |
time.sleep |
I/O (idle wait) | await asyncio.sleep |
Built-in open for file reads and writes |
I/O | Offload to a thread, or an async file library |
| Sync SDK clients for cloud or third-party APIs | I/O | Async variant of the SDK, or offload to a thread |
| Large JSON parsing, image processing | CPU | Thread if the library releases the GIL, otherwise a process pool |
| Password hashing, encryption, signing | CPU | Thread for GIL-releasing implementations, otherwise a process pool |
Async alternatives don't help with the CPU rows. A hash computation never waits on anything, so there is no point where it could yield. It occupies the loop until it finishes.
The hardest cases to catch are indirect. An async def endpoint calls load_profile(), a plain helper written long before the service went async, and three calls down that helper opens an ORM session or reads a template from disk. A review of the endpoint itself shows nothing wrong. FastAPI moves sync def dependencies to the threadpool, but it can't see inside your function body. Any sync helper you call directly from async def runs on the event loop. The same applies to logging handlers that ship records over the network synchronously and to libraries that lazily read configuration from disk on first use.
import asyncio
from pathlib import Path
from fastapi import FastAPI
app = FastAPI()
TEMPLATE_DIR = Path("/srv/app/templates")
def render_welcome(name: str) -> str:
# Plain sync helper written before the service went async.
# open() and read() block the calling thread until the disk read finishes.
with open(TEMPLATE_DIR / "welcome.txt", encoding="utf-8") as f:
return f.read().replace("{name}", name)
def load_profile(user_id: int) -> dict:
# Looks harmless at the call site. The blocking work is one level down.
return {"id": user_id, "greeting": render_welcome(f"user-{user_id}")}
@app.get("/bad/profile/{user_id}")
async def bad_profile(user_id: int):
# Direct sync call from async def: FastAPI does not offload it.
# The file read runs on the event loop and every other request on this worker waits.
return load_profile(user_id)
@app.get("/good/profile/{user_id}")
async def good_profile(user_id: int):
# Hand the whole sync call chain to a worker thread.
# The await yields, so the loop keeps serving other requests during the read.
return await asyncio.to_thread(load_profile, user_id)
An async endpoint that calls a sync helper with a file read buried inside it blocks the event loop, and wrapping the call in asyncio.to_thread moves that work off the loop.
Offloading blocking work: run_in_threadpool, asyncio.to_thread and process pools
run_in_threadpool from starlette.concurrency and asyncio.to_thread both take a blocking callable and its arguments, run it on a worker thread, and let you await the result. While that thread waits on I/O, the event loop keeps serving other requests. The two differ in which pool they use. run_in_threadpool goes through AnyIO's thread runner and shares the same capacity limiter FastAPI uses for plain def endpoints and sync dependencies, so your offloaded calls compete with those for slots. asyncio.to_thread uses the loop's default executor, which is a separate pool. Both copy the current contextvars context into the thread, so request-scoped values like trace IDs survive the hop. A raw loop.run_in_executor call does not copy them.
Threads only help when the work releases the GIL, as socket reads, file I/O and many C extensions do. Pure-Python CPU work holds the GIL, so a hashing loop on a thread still slows the event loop thread. For that, create a ProcessPoolExecutor once at startup and call loop.run_in_executor(pool, fn, *args). Arguments and results must be picklable. For longer jobs, a task queue with separate workers removes the work from the request path entirely.
Each option has costs:
- Pool exhaustion: when every thread slot is busy, new calls wait with no error and no log line, so the only symptom is rising latency.
- Pool size: a larger pool allows more concurrent calls but costs memory and adds contention.
- Overhead: dispatching to a thread can cost more than a sub-millisecond call.
- Thread safety: shared state that was safe on a single event loop thread can race once code runs on several threads.
- Process pools: they add serialization cost, per-process memory and lifecycle management.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool
def read_report(path: str) -> str:
# Blocking file I/O: the GIL is released while waiting on the OS, so a thread helps.
return Path(path).read_text()
def count_primes(limit: int) -> int:
# Pure-Python CPU work holds the GIL. It must be module-level so it can be pickled.
return sum(all(n % d for d in range(2, int(n**0.5) + 1)) for n in range(2, limit))
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create the process pool once at startup and reuse its workers across requests.
app.state.cpu_pool = ProcessPoolExecutor(max_workers=2)
yield
app.state.cpu_pool.shutdown()
app = FastAPI(lifespan=lifespan)
@app.get("/report")
async def report():
# Competes for the same AnyIO thread slots as plain def endpoints and sync dependencies.
text = await run_in_threadpool(read_report, "/srv/reports/daily.txt")
return {"chars": len(text)}
@app.get("/report-alt")
async def report_alt():
# Runs on the event loop's default executor, a separate pool.
text = await asyncio.to_thread(read_report, "/srv/reports/daily.txt")
return {"chars": len(text)}
@app.get("/primes/{limit}")
async def primes(limit: int):
loop = asyncio.get_running_loop()
# The argument and the result are pickled across the process boundary.
result = await loop.run_in_executor(app.state.cpu_pool, count_primes, limit)
return {"limit": limit, "primes": result}
The example offloads blocking file I/O with run_in_threadpool and asyncio.to_thread, and sends pure-Python CPU work to a ProcessPoolExecutor through loop.run_in_executor. The process pool is created once in the app's lifespan.
Native async libraries and alternative approaches
Offloading treats the symptom. When an endpoint does real concurrent I/O, such as fanning out to several upstream APIs or issuing independent queries, a native async HTTP client or database driver lets the event loop interleave those waits without spending a thread on each one.
Async drivers are not drop-in replacements, though:
- Transactions: session and transaction scopes become awaitable. Commit and rollback paths need
await, and context managers becomeasync with. Code that relied on implicit lazy loading inside a transaction can fail or block. - Connection pooling: async pools are tied to a specific event loop. Creating one at import time, or sharing it across loops, causes errors that are hard to diagnose.
- Testing: fixtures, test clients and database setup all need an async-aware test runner, and the loop's lifetime must match the pool's.
A common trap is pairing an async driver with sync ORM habits. Attribute access that triggers a lazy load issues I/O outside an await, so it either raises or quietly blocks.
Partial migrations make this worse. A codebase where some repositories are async, some are sync and run in the threadpool, and some are sync and called directly from async def handlers forces reviewers to trace every call path to know which thread it runs on.
| Approach | Fixes blocking? | Main cost |
|---|---|---|
| Native async libraries | Yes | Migration, new testing patterns |
| Threadpool offload | Contains it | Thread limits, thread safety |
| More worker processes | No, hides it | Memory, connection pools |
Adding workers only spreads the stalls across more event loops. Each process also opens its own connection pool, so total database connections grow with worker count and can exhaust the server's connection limit before CPU becomes the bottleneck.
Detecting blocking: debug mode, loop lag and an illustrative estimate
Before adding workers, confirm the loop is actually stalling. asyncio's debug mode, enabled with PYTHONASYNCIODEBUG=1 or asyncio.run(..., debug=True), logs any callback or task step that holds the loop longer than loop.slow_callback_duration (100 ms by default). The warning names the offending coroutine, which usually points straight at a sync driver call or a CPU-heavy parse inside an async def.
For a continuous signal, run a small loop-lag monitor task. It sleeps for a fixed interval, compares the actual elapsed time from loop.time() against the requested interval, and logs the difference. On a healthy loop the excess stays near zero. When something blocks, the excess jumps, and it jumps for every request on that worker at the same moment.
Profile under concurrent load, not single requests. A blocking call looks fine at one request at a time. The telltale symptom is latency that grows with concurrency even on cheap endpoints such as a health check.
Debug mode and tight lag sampling add overhead, so use them in development or staging.
Illustrative estimate (assumed numbers, not a benchmark). Assume a 200 ms blocking call (t), 50 concurrent requests (N) on one worker, and a thread pool of 40 (P).
| Execution model | Formula | Worst-case latency |
|---|---|---|
Blocking call in async def |
N × t |
50 × 0.2 s = 10 s |
Threadpool (def or run_in_threadpool) |
ceil(N / P) × t |
2 × 0.2 s = 0.4 s |
| Fully async I/O | ≈ t (if the downstream service accepts N concurrent requests) |
≈ 0.2 s |
Decision checklist and impact on cost and engineering time
| Situation | Use | Avoid |
|---|---|---|
| Every slow operation inside the handler is awaited (async DB driver, async HTTP client) | async def |
def, which spends a threadpool slot on work the loop could interleave |
| The handler calls blocking libraries (sync ORM, sync HTTP client, blocking SDKs) | def |
async def calling them inline, which stalls every request on the worker |
| Mostly async, with one unavoidable blocking call | async def plus run_in_threadpool or asyncio.to_thread |
Converting the whole endpoint, or leaving the call inline |
| CPU-bound work (parsing, image processing, crypto) | Process pool or a separate job queue | Threads, because the GIL serializes the work |
Default to def when you are unsure. A sync endpoint that runs in the threadpool performs predictably. A misjudged async def fails in a way that is hard to see.
Each option has a cost. An async rewrite replaces drivers, session handling and test fixtures. Every dependency has to be audited for hidden sync calls, and every code path needs retesting under concurrency. Scaling out with processes avoids that work but adds memory and connections.
Illustrative estimate (assumed numbers, not a benchmark):
- Extra memory =
W × M - Extra DB connections =
W × pool_size
With 4 extra workers at an assumed 150 MB each and a pool size of 10, that is 600 MB of memory and 40 connections. Check the connection total against your database's connection limit.
Choosing correctly at the start avoids the most expensive failure: p99 spikes that only appear under concurrent load, never in local tests. Engineers can spend days tracing those before finding one blocking call. A correct initial choice also prevents a full async migration when offloading one function would have been enough.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to fastapi blocking calls async, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.