March 12, 20266 min read

FastAPI at Scale: Async Patterns That Actually Matter

PythonFastAPIBackend

Most FastAPI tutorials stop at hello-world endpoints. Here's what I learned running async services under real traffic.

The async trap

Marking a route async def doesn't make it faster — it only helps if you await non-blocking I/O. If you call a synchronous library (like a standard ORM query) inside an async route, you block the event loop for every request.

The fix: use run_in_executor for blocking calls, or switch to an async driver (asyncpg, motor, aioredis).

Connection pool sizing

Default asyncpg pool size is 10. Under load that's almost always wrong. Start with min_size=5, max_size=20 and tune based on your DB server's max_connections and observed wait times. Don't guess — instrument with Prometheus counters on pool checkout time.

Background tasks vs workers

FastAPI's BackgroundTasks run in the same process after the response is sent. Good for fire-and-forget logging. Bad for anything that takes >500ms or needs retry logic. Use Celery or ARQ for real background work.

Lifespan for startup/shutdown

Move pool creation into the @asynccontextmanager lifespan handler — not into a global or @app.on_event (deprecated). This gives you clean startup sequencing and guaranteed teardown.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.pool = await asyncpg.create_pool(DATABASE_URL)
    yield
    await app.state.pool.close()

Where async hurts

CPU-bound work (image processing, heavy computation) doesn't benefit from async — it saturates one core. Offload to a process pool or a dedicated worker. Async shines for I/O-bound, high-concurrency workloads.