Async Best Practices
Rules for productive asyncio in FastAPI-era services - and when to stay sync.
How to Use This List
- Apply before converting a sync Flask app wholesale.
- Measure loop lag and p99 under load after changes.
- Prefer native async libraries over executor bridges when call volume is high.
A - When to Use Async
- Many concurrent I/O waits on one process. Hundreds of idle connections - async shines.
- Native async drivers available. httpx, asyncpg, redis asyncio clients.
- Framework is async-native. FastAPI, Starlette, aiohttp server.
- Skip async for CPU-only batch scripts. multiprocessing or sequential simpler.
- Do not async-wrap entire Django app without plan. ORM/sync middleware friction.
B - Event Loop Hygiene
- One long-lived loop per worker. Lifespan hooks - not
asyncio.runper request. - Never block the loop thread. No
time.sleep, sync requests, sync DB in coroutines. - Offload unavoidable blocking with
to_thread. Cap concurrency with semaphore. - Set default timeouts on HTTP and DB clients. Hung awaits stall health checks.
- Enable dev warnings for unawaited coroutines. Treat as CI errors where feasible.
C - Tasks & Scope
- Prefer TaskGroup for request-scoped child work (3.11+). No orphaned tasks after response.
- Limit fan-out with semaphores. Protect peers and file descriptors.
- Retrieve or log task exceptions. No silent fire-and-forget without done callback.
- Cancel work on client disconnect when business allows. Save resources.
- Use
return_exceptions=Truewhen partial success is valid. APIs returning per-item errors.
D - Resources
- Share AsyncClient/engine/pool at app scope. Created in lifespan, closed on shutdown.
- One DB session per request - not per row. Short transactions.
- async with for sessions, streams, locks. Guaranteed release on cancel.
- Propagate contextvars (request id) into thread workers. Logging continuity.
- Right-size pools to DB max_connections / upstream limits. Math, not defaults-only.
E - Operations
- Monitor event loop lag metric. Early signal of blocking regression.
- Load-test graceful shutdown. Draining tasks and closing pools.
- Document sync islands in architecture. Known executor bridges with retirement plan.
- pytest-asyncio strict mode in CI. Catch missing await in tests.
- Re-evaluate when deps ship async. Remove executor layer when library supports await.
FAQs
Is async always faster?
No - only when concurrent I/O waits dominate. CPU-bound or low concurrency may be slower due to complexity.
sync def route in FastAPI?
Runs in threadpool - OK for rare blocking libs; async def + native await scales better.
How much executor bridging is too much?
If most route time is in threads, migrate driver or deploy sync workers instead.
websockets and SSE?
Strong async use cases - long-lived connections on one loop.
async for CLI?
asyncio.run once at main - fine; keep internals async if using async HTTP.
Multiple loops?
Rare - one loop per thread; do not share clients across loops.
GIL and async?
Async does not parallelize CPU - offload compute separately.
Logging async?
Use async-friendly handlers or queue to thread listener - avoid blocking emit in hot path.
Third-party blocking SDK?
Isolate in thread pool, cache results, or replace vendor - document latency cost.
When to choose threads over async?
Moderate blocking I/O, few endpoints, team unfamiliar with async debugging.
Related
- Choosing a Concurrency Model - model pick
- Debugging Async Code - incident tools
- Structured Concurrency - TaskGroup
- Concurrency Best Practices - thread/process rules
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.