Introduction: Why FastAPI asyncio Best Practices Matter
When I first started building high-throughput services with FastAPI, I quickly learned that just “using async def” wasn’t enough. To get real performance benefits, I had to understand how asyncio and the event loop actually work under the hood. FastAPI is designed to shine in asynchronous environments, but it will only feel fast and responsive if I treat the event loop as a precious, shared resource.
The core idea behind FastAPI asyncio best practices is simple: never block the event loop. Every time a handler performs slow CPU work, calls a blocking library, or waits on external I/O without using proper async tools, it can freeze other requests. Under light load that might go unnoticed, but as traffic grows, those small missteps quickly turn into timeouts, increased latency, and unhappy users.
In my experience, the most painful production incidents have come from a single “innocent” blocking call buried deep in a dependency. One bad database call or file operation running on the main thread was enough to stall dozens of concurrent requests. By following a small set of focused FastAPI asyncio best practices, I’ve been able to prevent these bottlenecks, keep response times predictable, and make scaling out far more straightforward.
This article walks through the specific patterns I now use by default: keeping the event loop free of heavy work, isolating sync code, choosing compatible libraries, and using asyncio primitives correctly. If you apply these practices from the start, your non-blocking web APIs will stay fast and resilient even as your traffic and codebase grow.
1. Understand the FastAPI Event Loop Model Before You Optimize
Before I started applying any FastAPI asyncio best practices, I had to get clear on one thing: FastAPI itself doesn’t run your event loop. Instead, it sits on top of an ASGI server like Uvicorn, which owns the asyncio event loop and schedules all your async work. Once I understood that layering, my decisions about concurrency, background tasks, and I/O became much more deliberate and much less error-prone.
How FastAPI, Uvicorn, and asyncio Fit Together
In a typical deployment, Uvicorn starts an asyncio event loop in each worker process and mounts your FastAPI application as an ASGI app. Every incoming HTTP request becomes a coroutine that the event loop manages. FastAPI focuses on routing, dependency injection, and validation, while Uvicorn and asyncio handle scheduling and I/O multiplexing.
In practice, that means:
- One event loop per process: All your async endpoints in that worker share the same loop.
- Each request is a task: The loop interleaves these tasks whenever they hit an
awaitpoint. - Async I/O is cooperative: Tasks yield control when awaiting I/O so others can execute in the meantime.
When I design a new endpoint now, I think in terms of “what will this coroutine do to the event loop?” instead of just “does this function compile?” That mindset shift alone prevents a lot of hidden bottlenecks.
Synchronous vs Asynchronous Work on the Event Loop
The event loop is optimized for non-blocking I/O, not for heavy CPU work or blocking system calls. Async functions should spend most of their time awaiting I/O (database calls, HTTP requests, queues), not crunching data on the main thread. Whenever I accidentally mix in synchronous calls, I’m effectively freezing the loop until that work finishes.
Here’s a simplified example I use when explaining this to teammates:
from fastapi import FastAPI
import time
app = FastAPI()
# ❌ Blocks the event loop
@app.get("/blocking")
def blocking_endpoint():
time.sleep(2) # blocks the thread and the event loop worker
return {"status": "done"}
# ✅ Plays nicely with the event loop
@app.get("/non_blocking")
async def non_blocking_endpoint():
import asyncio
await asyncio.sleep(2) # yields control back to the event loop
return {"status": "done"}
Both endpoints “sleep” for two seconds, but only the async version lets other requests progress during that time. In my experience, mixing just a few time.sleep() or blocking SDK calls into a busy API is enough to cause cascading latency spikes across all concurrent requests.
Common Misconceptions About Concurrency in FastAPI
When I review code for teams moving to FastAPI, I see a few recurring misconceptions that lead to poor performance, even when they think they’re following FastAPI asyncio best practices:
- “If I use
async def, it’s automatically concurrent.” In reality,async defjust means the function returns a coroutine. If that coroutine calls blocking functions internally, the event loop still stalls. - “More workers always means more throughput.” Spinning up more Uvicorn workers helps only if each worker uses the event loop efficiently. If every worker spends most of its time blocked on sync I/O, you mainly add overhead and memory use.
- “Awaiting a sync function makes it non-blocking.” I learned this the hard way: wrapping a synchronous, blocking library call in an
async defand thenawait-ing it does not turn it into async I/O. You need to offload it (for example, withasyncio.to_threador a proper async client). - “CPU-bound work is fine in async endpoints.” Heavy computation in an async route (like big JSON transformations or complex loops) can still block the loop. I now treat CPU-heavy tasks as candidates for background workers or thread/process pools.
Once I stopped assuming the event loop was “magic concurrency” and started treating it as a single-threaded scheduler that I had to protect, my FastAPI services became far more predictable under load. The rest of the FastAPI asyncio best practices in this article build on this mental model: keep the loop free, use non-blocking libraries, and offload what doesn’t belong there. How does asyncio actually work? – Stack Overflow
2. Keep the Event Loop Non-Blocking: Avoid CPU-Bound Work in async Def
One of the first FastAPI asyncio best practices I try to drill into teams is this: async is not a free performance booster for CPU-heavy logic. The asyncio event loop is great at juggling many I/O-bound tasks, but it’s still fundamentally single-threaded. If I drop a big CPU-bound chunk of work into an async def endpoint, I effectively pause the entire loop for every other request in that worker.
When I debug real-world performance problems, they’re often caused by innocent-looking loops: large JSON transformations, encryption, image processing, or complex business rules that chew CPU for hundreds of milliseconds or more. The key is to keep that work off the main event loop and let async focus on I/O orchestration.
Why CPU-Bound Work Freezes Your FastAPI App
Asyncio provides concurrency by letting tasks yield control when they hit an await on non-blocking I/O. CPU-bound code doesn’t naturally yield; it just runs until completion. So if an endpoint spends 500 ms crunching numbers in pure Python, the event loop can’t schedule other tasks during that time.
Here’s a simplified example I’ve seen in performance reviews:
from fastapi import FastAPI
app = FastAPI()
# ❌ CPU-bound work directly on the event loop
@app.get("/hash-all")
async def hash_all():
# Pretend this list is large
data = [b"some-bytes-%d" % i for i in range(100_000)]
import hashlib
results = []
for item in data: # tight CPU loop
digest = hashlib.sha256(item).hexdigest()
results.append(digest)
return {"count": len(results)}
Under light load this might look fine, but with concurrent traffic the latency of other endpoints will spike because the worker is stuck burning CPU in this handler. In my own services, one heavy analytics endpoint like this was enough to degrade p95 latency across unrelated routes.
Offloading Heavy Tasks to Threads or Background Workers
When I realize an endpoint needs heavy processing, I have two main options:
- Short, contained CPU work: Offload to a thread using
asyncio.to_thread(Python 3.9+) or an executor. - Long-running or batch work: Push it to a background worker (Celery, RQ, or a custom job system) and return quickly.
Here’s how I typically refactor CPU-bound logic into a thread while keeping the FastAPI endpoint async-friendly:
from fastapi import FastAPI
import asyncio
import hashlib
app = FastAPI()
def hash_all_sync(data: list[bytes]) -> list[str]:
# Pure CPU work in a regular sync function
results = []
for item in data:
digest = hashlib.sha256(item).hexdigest()
results.append(digest)
return results
@app.get("/hash-all-safe")
async def hash_all_safe():
data = [b"some-bytes-%d" % i for i in range(100_000)]
# ✅ Offload CPU work to a separate thread so the event loop can keep running
results = await asyncio.to_thread(hash_all_sync, data)
return {"count": len(results)}
In my experience, this single change often turns a spiky, unpredictable endpoint into something that coexists peacefully with the rest of the app. The event loop stays responsive while the heavy work happens in a worker thread.
For truly heavy or long-running jobs (for example, generating large reports or training models), I usually don’t run them in API threads at all. Instead, the API enqueues a job to a worker system and returns a task ID so clients can poll or use webhooks. Handling CPU-Bound Tasks in FastAPI Without Killing Performance
Spotting and Refactoring Hidden Blocking Calls
Not all blocking work is obviously CPU-bound. Some of the worst offenders I’ve found in code reviews come from third-party libraries that look harmless but hide synchronous I/O or CPU-heavy logic under the hood. A few patterns I watch for:
- Sync HTTP clients (e.g.,
requests) called insideasync defroutes. - Database drivers that aren’t designed for asyncio.
- Filesystem operations like large file reads/writes using blocking APIs.
- Crypto, compression, and image processing running inline inside async handlers.
One technique that helped me a lot was temporarily enabling detailed logging and timing per endpoint to see where time is actually spent. When a handler consistently takes longer than expected, I inspect it for:
- Long loops without any
await. - Heavy CPU operations repeated for each item in a list.
- Libraries that don’t offer async variants.
Once I find those hotspots, I apply a simple decision tree:
- Is it I/O? Replace with an async library if possible (for example,
httpx.AsyncClientinstead ofrequests). - Is it CPU-bound but quick? Wrap it in
asyncio.to_threador a small executor pool. - Is it CPU-bound and slow? Move it to a background worker pattern and keep the endpoint non-blocking.
In my own practice, the services that aged the best were the ones where I treated the event loop like a “traffic controller” and pushed everything heavy to specialized workers or threads. Following this principle consistently is one of the most impactful FastAPI asyncio best practices you can adopt for stable, low-latency APIs.
3. Use True Async I/O for Databases, HTTP Clients, and File Access
After I wrapped my head around the event loop model, the next big unlock was realizing how much damage a single blocking I/O call can do inside an async def route. If my database client, HTTP client, or file access code is synchronous, then my entire FastAPI asyncio setup is basically cosmetic. To get the real benefits of FastAPI asyncio best practices, I have to use genuine non-blocking I/O end to end.
Choosing Async-Friendly Database Drivers and ORMs
Databases are usually the hottest path in an API, so they’re the first place I look. The rule I follow is simple: if the driver can’t expose await-able operations, it doesn’t belong directly in an async path.
These are patterns that have worked well for me:
- PostgreSQL:
asyncpg, or SQLAlchemy 2.x with its async engine. - MySQL:
aiomysqlor async support via compatible ORMs. - NoSQL (MongoDB, Redis): Native async clients like
motorfor MongoDB andredis.asynciofor Redis.
Here’s a minimal example using SQLAlchemy’s async engine that I’ve used as a starting template:
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/app"
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
AsyncSessionLocal = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
app = FastAPI()
async def get_session() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
@app.get("/users-count")
async def users_count(session: AsyncSession = Depends(get_session)):
result = await session.execute(text("SELECT count(*) FROM users"))
(count,) = result.scalar_one_or_none() or (0,)
return {"count": count}
The critical detail is that every database operation is awaited, which lets the event loop handle other requests while the DB does its work. Earlier in my career, I tried to “fake it” by calling a sync ORM from an async route and paid the price in latency as concurrency grew. Now, if I absolutely must use a sync-only ORM, I isolate it in a separate worker or at least wrap calls with asyncio.to_thread as a temporary compromise.
Switching from requests to Async HTTP Clients
External HTTP calls are another common source of blocking I/O. I still see a lot of code like this in async routes:
# ❌ This blocks the event loop
import requests
@app.get("/upstream")
async def call_upstream():
resp = requests.get("https://api.example.com/data")
return resp.json()
Using requests inside an async path negates most of the concurrency benefits I’m trying to get. My go-to fix is to switch to an async HTTP client such as httpx.AsyncClient or aiohttp. Here’s the pattern I use most often with httpx:
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.on_event("startup")
async def startup_event():
# Reuse a single async client with connection pooling
app.state.http_client = httpx.AsyncClient(timeout=10.0)
@app.on_event("shutdown")
async def shutdown_event():
await app.state.http_client.aclose()
@app.get("/upstream")
async def call_upstream():
client: httpx.AsyncClient = app.state.http_client
resp = await client.get("https://api.example.com/data")
return resp.json()
In my experience, configuring a single shared async client like this both improves performance (connection pooling) and avoids subtle resource leaks. It also keeps the event loop free while upstream services respond.
Using Async File Access (When It Actually Helps)
File access is a bit trickier. On many systems, disk I/O is effectively blocking at the OS level, but using async wrappers can still help avoid tying up your main thread when dealing with many concurrent file operations. When I build FastAPI services that read or write lots of small files (uploads, logs, reports), I prefer async-friendly tools, or I deliberately push heavy file handling into background tasks.
For basic async file reads and writes, I often reach for aiofiles:
from fastapi import FastAPI, UploadFile, File
import aiofiles
app = FastAPI()
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
# ✅ Non-blocking style file write
async with aiofiles.open(f"/tmp/{file.filename}", "wb") as out:
while chunk := await file.read(1024 * 1024):
await out.write(chunk)
return {"filename": file.filename}
On some deployments, I’ve measured only modest gains from async file I/O compared to offloading to a thread. But what matters most for me is that my FastAPI endpoints don’t sit in long blocking open/read/write calls. If file work gets heavy (e.g., zipping large directories, transcoding media), I treat it like other CPU-bound tasks and push it to workers or threads while keeping my HTTP handlers lean. Concurrency and async / await – FastAPI
When I look back at the FastAPI services that scaled the cleanest, they all have one thing in common: every critical integration—database, upstream APIs, file system—was designed with true async I/O in mind. Aligning those pieces with FastAPI asyncio best practices is what turns “theoretically async” code into APIs that stay responsive under real-world load.
4. Structure Your FastAPI Endpoints for Predictable Concurrency
Once I had async I/O in place, the next gains came from how I structured my FastAPI endpoints and dependencies. Predictable concurrency isn’t just about libraries; it’s about keeping each request path simple, explicit, and free of surprises that could block the event loop. When I design routes now, I think in terms of a short, well-defined async pipeline per request.
Designing Lean, Single-Responsibility Path Operations
In my experience, the most stable FastAPI asyncio architectures keep path operations as thin orchestration layers: validate input, call a few async services, shape the response. Heavy business logic and cross-cutting concerns live in separate functions or classes that can be tested and tuned independently.
Here’s a structure I use a lot:
from fastapi import FastAPI, Depends
app = FastAPI()
class UserService:
async def get_user_profile(self, user_id: int):
# Async database + HTTP calls would live here
return {"id": user_id, "name": "Alice"}
async def get_user_service() -> UserService:
# Construct or fetch from container if needed
return UserService()
@app.get("/users/{user_id}")
async def get_user(user_id: int, service: UserService = Depends(get_user_service)):
# ✅ Endpoint is just orchestration
profile = await service.get_user_profile(user_id)
return profile
By keeping handlers small like this, it’s much easier for me to scan for potential blocking calls and reason about concurrency. If performance degrades, I know to look inside the service methods, not across a massive, monolithic path operation.
Writing Async-Safe Dependencies and Middlewares
Dependencies are a common place where hidden blocking creeps in. I’ve seen “harmless” dependencies open sync DB connections, read big files, or call external APIs with requests, all before the endpoint logic even runs. Now I treat dependencies with the same rigor as endpoints: if they might do I/O, they should be async def and use async libraries.
For example, an async dependency that loads a current user from a token:
from fastapi import Depends, HTTPException, status
async def get_current_user(token: str = Depends(...), service: UserService = Depends(get_user_service)):
# ✅ Async DB/HTTP calls hidden behind service
user = await service.get_user_from_token(token)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return user
@app.get("/me")
async def read_me(current_user = Depends(get_current_user)):
return current_user
When I review code now, I always walk the full dependency tree of critical endpoints. Anything that can run per request is part of the concurrency story, and it must honor FastAPI asyncio best practices just like the handler itself.
Managing Background Tasks and In-Request Parallelism Carefully
FastAPI makes it easy to fire off background work or run multiple awaits in parallel, but I’ve learned to be cautious. Spawning too many concurrent tasks inside a single request can overload shared resources like DB pools or external APIs.
For bounded in-request parallelism, I use structured patterns like:
import asyncio
@app.get("/aggregate")
async def aggregate():
# ✅ Explicit parallelism with clear bounds
task1 = asyncio.create_task(fetch_service_a())
task2 = asyncio.create_task(fetch_service_b())
result_a, result_b = await asyncio.gather(task1, task2)
return {"a": result_a, "b": result_b}
For background work that outlives the request (sending emails, logging, slow notifications), I either use FastAPI’s background tasks for short jobs or a dedicated queue for anything heavier. The goal is always the same: keep request paths lean, predictable, and fully cooperative with the event loop so concurrency remains stable as traffic grows.
5. Manage Background Tasks and Long-Running Work Safely
Some of the hardest production bugs I’ve dealt with in FastAPI apps came from “helpful” background work that quietly starved the event loop. Managing long-running operations is where FastAPI asyncio best practices really separate stable systems from flaky ones. My rule of thumb is simple: requests should be short; work can be long—but the long work must run in the right place.
When to Use FastAPI BackgroundTasks (and When Not To)
BackgroundTasks are great for quick, non-critical follow-up work that doesn’t justify a full job queue. I use them for things like logging, analytics pings, and low-cost notifications that complete in a few hundred milliseconds.
Typical pattern:
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
def send_email_sync(address: str, subject: str):
# Assume this uses a non-blocking library or is short
...
@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
# ✅ Schedule lightweight follow-up work
background_tasks.add_task(send_email_sync, email, "Welcome!")
return {"status": "accepted"}
In my experience, BackgroundTasks become dangerous when they start doing heavy CPU work, long DB loops, or large file processing. At that point, I move the job to a proper worker system so API workers stay free for new requests. Background Tasks – FastAPI
Using asyncio.create_task and to_thread Without Leaking Tasks
For in-process async work that should run alongside the current request, I use asyncio.create_task carefully and always keep a reference so I can await or monitor it. Fire-and-forget tasks are a common source of leaks and unhandled exceptions.
import asyncio
from fastapi import FastAPI
app = FastAPI()
async def audit_log(event: dict):
# Async DB/HTTP logging here
...
@app.post("/action")
async def do_action(payload: dict):
# ✅ Spawn a supervised async task
task = asyncio.create_task(audit_log({"payload": payload}))
# Optionally await or attach callbacks if you must track completion
# await task # only if you want to block until it's done
return {"status": "ok"}
When work is CPU-bound, I prefer asyncio.to_thread so the event loop can keep serving requests while a separate thread handles the heavy lifting. One thing I learned the hard way is to avoid spawning unbounded threads per request; I keep the offloaded work tight and idempotent so I can move it to a real job queue later if needed.
Offloading Truly Long-Running Jobs to External Workers
For jobs measured in seconds or minutes—report generation, batch imports, media processing—I don’t let them live in the API process at all anymore. I push them to an external worker (Celery, RQ, Dramatiq, custom worker) and have the API return a task ID immediately.
The basic pattern I use:
- API receives a request and validates input.
- API enqueues a job message (Redis, RabbitMQ, etc.).
- API returns a 202 Accepted with a job ID and maybe a status endpoint URL.
- Worker processes the job independently and updates status storage.
This approach keeps FastAPI focused on fast, predictable HTTP responses while workers handle the heavy lifting. It also aligns perfectly with FastAPI asyncio best practices: the event loop coordinates requests and quick I/O, and anything that can clog it is moved out of the hot path.
6. Tune Concurrency: Worker Counts, Connection Limits, and Timeouts
Once my FastAPI code was mostly async-correct, the next big gains came from tuning the runtime around it. I’ve seen perfectly written async code bog down in production simply because Uvicorn worker counts, DB pools, or HTTP client limits were misconfigured. Good FastAPI asyncio best practices aren’t just about code—they’re about sizing concurrency knobs to match your hardware and workloads.
Choosing Uvicorn Worker Counts for Your Hardware
In my experience, Uvicorn worker count is where many teams start guessing. A simple rule that’s worked well for me in CPU-light, I/O-bound APIs is:
- Base heuristic: start with
workers = number_of_cpu_cores. - Adjust up if work is mostly I/O (lots of waiting on DB or external APIs).
- Adjust down if work is CPU-heavy (or move that CPU work out of the workers).
For example, on an 8-core machine I might start with 6–8 workers, then run a load test and watch p95 latency and CPU utilization. If CPU is low but latency is high, I consider adding a worker or verifying that my code is truly non-blocking before I crank the number further.
Using Gunicorn with Uvicorn workers, a typical config I’ve run looks like this:
# Example: 8 workers, async worker class, reasonable timeout export WEB_CONCURRENCY=8 gunicorn "app.main:app" \ -k uvicorn.workers.UvicornWorker \ --workers $WEB_CONCURRENCY \ --timeout 60
One thing I learned the hard way: more workers are not always better. If each worker opens many DB connections and HTTP clients, you can overwhelm shared backends long before the CPU is saturated.
Right-Sizing Database Pools and Async Client Limits
Database connection pools and HTTP client limits should be tuned in relation to your worker count. I try to think in terms of total concurrent connections to each backend, not just per-process settings.
For a DB pool, a common starting point for an async engine is:
- pool_size: 5–10 connections per worker.
- max_overflow: small burst capacity (2–5) if supported.
With 6 workers and a pool size of 5, that’s up to 30 concurrent DB connections. If the DB can’t handle that, I reduce either worker count or pool size until load tests show stable behavior.
from sqlalchemy.ext.asyncio import create_async_engine
DATABASE_URL = "postgresql+asyncpg://user:pass@db:5432/app"
engine = create_async_engine(
DATABASE_URL,
echo=False,
pool_size=5, # per worker
max_overflow=2, # small burst
pool_timeout=30,
)
For async HTTP clients like httpx.AsyncClient or aiohttp.ClientSession, I explicitly configure connection limits instead of relying on defaults. Otherwise, concurrent requests can open far more upstream connections than I intended.
import httpx
limits = httpx.Limits(
max_keepalive_connections=50,
max_connections=100, # per worker
)
client = httpx.AsyncClient(limits=limits, timeout=10.0)
In my own deployments, getting these numbers roughly right made services more predictable than any micro-optimization in code.
Timeouts and Circuit-Breaker Thinking
Timeouts are my safety net when a dependency misbehaves. Without strict timeouts, async apps can pile up hung tasks and exhaust resources even if everything else follows FastAPI asyncio best practices.
For outbound calls (DB, HTTP, caches), I always:
- Set reasonable connect and read timeouts (often 1–5s for internal services).
- Use per-request timeouts rather than only global ones when the library supports it.
- Log and handle timeout errors explicitly, sometimes with fallbacks.
With httpx I like to be explicit:
import httpx
client = httpx.AsyncClient(
timeout=httpx.Timeout( # seconds
connect=2.0,
read=3.0,
write=3.0,
pool=5.0,
)
)
On the server side, I also make sure the reverse proxy (NGINX, Traefik, load balancer) and Gunicorn/Uvicorn timeouts align with my expectations for maximum request duration. In my experience, a well-tuned combination of worker counts, pool sizes, and timeouts turns an “it usually works” FastAPI deployment into one that behaves consistently under real load.
7. Verify Your FastAPI asyncio Best Practices with Load Testing and Profiling
The biggest mindset shift for me with FastAPI asyncio best practices was realizing that assumptions don’t count—only measurements do. I’ve seen apps that “felt” async-safe crumble the moment we put real load on them. To know whether your non-blocking design truly works, you need to stress it, observe the event loop, and profile where time is actually going.
Load Testing FastAPI Endpoints for Concurrency Behavior
I like to run load tests early, even on staging, to catch blocking code before it hits production. Tools like Locust, k6, or even simple Python scripts with asyncio are enough to reveal whether the event loop stays responsive under concurrency.
Here’s a minimal async load generator I’ve used to sanity-check endpoints:
import asyncio
import httpx
URL = "http://localhost:8000/health"
CONCURRENCY = 100
REQUESTS_PER_WORKER = 50
async def worker(name: int):
async with httpx.AsyncClient(timeout=5.0) as client:
for i in range(REQUESTS_PER_WORKER):
resp = await client.get(URL)
if resp.status_code != 200:
print(f"worker {name}: unexpected status {resp.status_code}")
async def main():
tasks = [asyncio.create_task(worker(i)) for i in range(CONCURRENCY)]
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
When I run something like this, I’m looking at:
- Latency curves (p50, p95, p99): sudden spikes often flag blocking I/O or CPU work.
- Error rates: timeouts and 5xxs under load often mean resource exhaustion (DB pool, HTTP client limits, workers).
- CPU usage: high CPU + poor scaling hints at CPU-bound work on the event loop.
Once, I thought an endpoint was fully async because it only did DB calls—load testing quickly showed rising latency, and profiling later revealed a sync ORM hiding behind those calls.
Profiling and Tracing to Find Hidden Blocking Calls
When numbers look off, I shift to profiling and tracing. My goal is to find any sync call paths inside async def handlers or dependencies. I’ve had good results combining:
- Standard profilers like
cProfileorpy-spyto see where CPU time is burned. - APM tools (New Relic, Datadog, OpenTelemetry-based stacks) for request traces and DB/HTTP timings.
- Structured logging around slow sections to narrow down hotspots.
A simple pattern I often start with is timing critical sections manually to see if they’re unexpectedly slow under load:
import time
import logging
from fastapi import FastAPI
logger = logging.getLogger(__name__)
app = FastAPI()
@app.get("/expensive")
async def expensive():
start = time.perf_counter()
result = await do_some_async_work()
elapsed = (time.perf_counter() - start) * 1000
if elapsed > 200: # ms threshold I care about
logger.warning("/expensive took %.2f ms", elapsed)
return {"elapsed_ms": elapsed, "result": result}
In my own projects, profiling has repeatedly surfaced things like stray requests.get calls, large JSON parsing in tight loops, or sync file access in dependencies. Once I find them, I either swap in async libraries, offload to threads, or refactor to background workers.
Monitoring Event Loop Health and Latency
The last piece I rely on is direct monitoring of the event loop itself. Even with good load tests and profiling, the loop can still be starved intermittently if something starts blocking under specific conditions.
There are a few tactics I’ve found practical:
- Event loop lag metrics: measure how far timers drift from their scheduled times to estimate loop blockage.
- Periodic health-check coroutines: tiny tasks scheduled at intervals to detect if they’re being delayed.
- APM “event loop utilization” metrics: many modern APMs now expose this directly.
Here’s a rough sketch of a simple loop-lag logger I’ve used on internal services:
import asyncio
import time
import logging
logger = logging.getLogger(__name__)
async def monitor_event_loop(interval: float = 0.5, warn_threshold_ms: float = 100.0):
"""Log when event loop appears blocked beyond threshold."""
loop = asyncio.get_running_loop()
next_ts = loop.time() + interval
while True:
await asyncio.sleep(interval)
now = loop.time()
lag_ms = (now - next_ts) * 1000
if lag_ms > warn_threshold_ms:
logger.warning("Event loop lag detected: %.2f ms", lag_ms)
next_ts = now + interval
# Start this in a startup event or top-level task in your app.
When I first added this kind of monitoring, I was surprised how often the event loop lagged during traffic spikes, even on systems that “looked fine” from a request/second perspective. Fixing those issues—usually by removing blocking calls or slimming down dependencies—made the APIs feel consistently snappy.
For me, the real confidence in FastAPI asyncio best practices comes only after this feedback loop: implement async patterns, load test, profile, watch the event loop, and iterate. Once that’s in place, I trust the system a lot more than any static code review.
Conclusion: Putting FastAPI asyncio Best Practices Into Daily Use
Over time, what’s helped me most with FastAPI asyncio best practices is treating them less like theory and more like a daily discipline. Every new endpoint, dependency, or integration is an opportunity either to keep the event loop clean—or to sneak in blocking work that will hurt you later under load.
Recap of the Core Strategies
Here’s how I mentally summarize the seven practices from this guide:
- Understand the event loop and avoid blocking it with CPU-heavy work.
- Isolate sync code in threads or workers instead of calling it directly from async handlers.
- Use true async I/O for databases, HTTP clients, and file access.
- Keep endpoints lean and design async-safe dependencies and orchestration.
- Handle background and long-running work through BackgroundTasks,
asyncio, or external workers appropriately. - Tune concurrency knobs (workers, pools, timeouts) to match your hardware and backends.
- Verify with load tests and profiling instead of trusting intuition.
A Practical Checklist for Daily Development
When I review code now, I run through a quick checklist:
- Is every
async defpath free of obvious blocking calls? - Are DB and HTTP calls using async libraries with sensible limits and timeouts?
- Is heavy or long-running work moved to threads, BackgroundTasks, or external workers?
- Do worker counts and pool sizes make sense for the environment?
- Has this code been exercised under realistic concurrency with basic load tests?
If I can answer “yes” to those questions, I’m usually confident the service will behave well in production. From there, it’s just a matter of watching metrics and iterating as traffic and requirements evolve.

Hi, I’m Cary Huang — a tech enthusiast based in Canada. I’ve spent years working with complex production systems and open-source software. Through TechBuddies.io, my team and I share practical engineering insights, curate relevant tech news, and recommend useful tools and products to help developers learn and work more effectively.





