FastAPI Async Patterns: When to Use Background Tasks vs Real Concurrency
FastAPI is fast, but speed is not the same as correctness. Learn where async, background jobs, and bounded concurrency matter in Python services.
On this page
Key takeaways
- Async Python helps with I/O-bound work; it does not make CPU-heavy work faster by default.
- The biggest performance wins come from reducing blocking calls and tuning connection pools.
- Background jobs are great for out-of-band workflows, but they need durability and observability.
- Use structured concurrency and request-scoped dependencies deliberately to avoid hidden state leaks.
- The best Python services expose clear runtime trade-offs, not just clever async syntax.
Architecture
FastAPI gives teams a highly productive API surface, but the architecture question remains the same as in any framework: where are the I/O boundaries, what is blocking, and what should happen asynchronously? Async code is ideal for waiting on a database, HTTP client calls, message brokers, or cache reads. It is not a magic solution for CPU-bound work such as image processing, encryption, or large JSON serialization on the same event loop. Correct Python service design balances request latency, backend saturation, and observability.
Code examples
Bounded async dependency with a database session
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
app = FastAPI()
engine = create_async_engine("postgresql+asyncpg://user:pass@db/app")
async def get_db() -> AsyncSession:
async with engine.begin() as conn:
yield conn
@app.get("/health")
async def health(db: AsyncSession = Depends(get_db)):
return {"status": "ok"}Background task for non-blocking follow-up
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def send_email(email: str):
print(f"sending email to {email}")
@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(send_email, email)
return {"status": "accepted"}Troubleshooting
The API is slow even though the route is async
Timeout waiting for downstream HTTP callTraceback (most recent call last):
File "app/main.py", line 84, in endpoint
await client.get("https://api.example.com")
Resolution: Check for blocking sync calls, large DB queries, or unbounded connection pools. Async code only helps when the underlying operations are non-blocking and correctly awaited.
A request hangs because the event loop is saturated
asyncio.exceptions.TimeoutError: operation exceeded time limit...
Resolution: Use connection pooling, lower per-request fan-out, and background tasks for expensive jobs that do not need to complete before the HTTP response returns.
Edge cases
- Avoid CPU-heavy work inside the event loop; use a worker pool or a separate service for that job.
- Session and connection lifecycle bugs are often invisible until concurrency rises.
- A background queue without retries and observability is a reliability trap disguised as simplicity.
Async is not a performance button
The main mistake in Python services is assuming that adding async def everywhere automatically makes the app fast. That is only true when the underlying work is I/O-bound and the service avoids blocking calls, large synchronous fan-outs, and thread starvation.
FastAPI is an excellent API framework because it is explicit about request lifecycle and dependency injection. The real design question is whether the route is waiting on a slow database, a remote API, or a local CPU-bound transformation. If it is waiting on I/O, async helps. If it is calculating or processing large payloads, the event loop may not be the right place.
Separate request latency from background work
A good product architecture separates user-facing latency from operational tasks. For a webhook or sign-up flow, the HTTP request may only need to acknowledge receipt. The expensive or slow pieces—sending email, processing a PDF, syncing data to a downstream service—should move to a durable background queue.
This keeps the API responsive and gives the team a better place to implement retries, rate limits, and observability. The quickest route to a flaky service is to keep every downstream task in the request thread and hope the queue never gets overloaded.
Use dependencies and context carefully
FastAPI dependencies are powerful, but they can also hide state if they are not clear about scope and lifetime. A request-scoped dependency must not carry shared mutable state across requests unless that is explicitly designed. This matters especially with database sessions, caches, and external clients.
Avoid patterns where a global client or singleton cache silently accumulates stale state. Testing and observability become much more difficult when the server behaves differently depending on concurrency timing.
Bounded concurrency matters more than “more async”
Async code scales better than blocking code for many workloads, but not infinitely. If the service fans out to dozens of downstream calls per request, you may saturate the process or exhaust connection pools. Bounded concurrency and clear retries are often more important than the syntax itself.
The service should enforce limits on:
- outbound HTTP fan-out;
- DB connection usage;
- queue message throughput;
- worker concurrency in background tasks.
This is how Python services stay stable under load instead of becoming a bursty event-loop bottleneck.
Final guidance
The best FastAPI services use async where it matters, keep CPU work out of the request path, and make background jobs durable and observable. If you can separate request latency from expensive follow-up work, your system becomes easier to reason about and easier to scale.