Async, Background Tasks, and Observability in FastAPI
An application can be structurally sound and correctly modelled and still fall over the first time real traffic arrives. This area covers what happens after the design is right: what actually runs on which thread, what work should not be in the request at all, how shared resources behave when many requests want them at once, and how you find out what went wrong afterwards.
It is the runtime half of the site, and it depends on the other two. Core Architecture and Routing Patterns provides the lifespan hook that owns pools and the middleware layer that stamps a request identifier; Advanced Pydantic Validation and Serialization determines how much CPU each response costs. The home page has the full map.
Seven guides sit beneath this one. Async Correctness and Concurrency is the foundation — nothing else in the area behaves as expected if the loop is blocked. Async Database Sessions covers the resource most services spend most of their time waiting on. Background Task Processing covers moving work off the request path. Caching Strategies covers not doing the work at all. Rate Limiting and Throttling covers refusing work you cannot afford. Observability and Tracing covers knowing what happened. And Testing FastAPI Applications covers proving any of it before production does.
Those three timelines are not a sketch. They are the recorded output of the example in the next section, which is worth reading closely because the middle row is the failure mode most FastAPI services ship with.
What runs where
FastAPI decides where your handler runs from one keyword. An async def handler is scheduled on the event loop. A plain def handler is dispatched to a worker thread so it cannot hold the loop. The framework is doing something helpful and largely invisible, and the invisible part is where the trouble starts: nothing checks that an async def handler actually yields.
The app below defines all three combinations and a /selftest endpoint that fires two concurrent requests at whichever one you name, recording the order events actually happened in and which thread each ran on.
"""Where a handler runs, and what that does to two requests arriving together."""
import asyncio
import threading
import time
import httpx
from fastapi import FastAPI
app = FastAPI()
EVENTS: list[str] = []
def note(event: str) -> None:
thread = threading.current_thread().name
kind = "loop" if thread.startswith("MainThread") else "worker-thread"
EVENTS.append(f"{event} [{kind}]")
@app.get("/awaited/{tag}")
async def awaited(tag: str) -> dict[str, str]:
"""Async and await-clean: the sleep yields, so the loop can start the other request."""
note(f"awaited {tag}: enter")
await asyncio.sleep(0.05)
note(f"awaited {tag}: exit")
return {"handler": "awaited", "tag": tag}
@app.get("/blocking/{tag}")
async def blocking(tag: str) -> dict[str, str]:
"""Async but NOT await-clean: time.sleep does not yield."""
note(f"blocking {tag}: enter")
time.sleep(0.05)
note(f"blocking {tag}: exit")
return {"handler": "blocking", "tag": tag}
@app.get("/sync/{tag}")
def sync_handler(tag: str) -> dict[str, str]:
"""Plain def: FastAPI runs it in the threadpool, so blocking here does not hold the loop."""
note(f"sync {tag}: enter")
time.sleep(0.05)
note(f"sync {tag}: exit")
return {"handler": "sync", "tag": tag}
@app.get("/selftest/{style}")
async def selftest(style: str) -> dict[str, list[str]]:
"""Fire two requests at the same handler concurrently and report the real event order."""
EVENTS.clear()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://inner") as client:
await asyncio.gather(
client.get(f"/{style}/A"),
client.get(f"/{style}/B"),
)
return {"order": list(EVENTS)}
The requests are made by the app against itself through ASGITransport, so the concurrency is genuine rather than simulated. Recorded output:
$ GET /selftest/awaited
200 OK
{
"order": [
"awaited A: enter [loop]",
"awaited B: enter [loop]",
"awaited A: exit [loop]",
"awaited B: exit [loop]"
]
}
$ GET /selftest/blocking
200 OK
{
"order": [
"blocking A: enter [loop]",
"blocking A: exit [loop]",
"blocking B: enter [loop]",
"blocking B: exit [loop]"
]
}
$ GET /selftest/sync
200 OK
{
"order": [
"sync A: enter [worker-thread]",
"sync B: enter [worker-thread]",
"sync A: exit [worker-thread]",
"sync B: exit [worker-thread]"
]
}
The first and third orderings interleave: both requests were in flight at once. The middle one does not. blocking A ran to completion before blocking B was allowed to start, even though both arrived together and neither depends on the other.
The instructive part is the thread annotation. In the middle case the handler was on the loop — exactly like the first case — and it was declared async def, exactly like the first case. The only difference is that it called something that does not yield. There is no error, no warning, and no signal of any kind. At a concurrency of one, all three handlers are indistinguishable, which is precisely why this reaches production: it is invisible in development and invisible in most test suites.
The third row is the important consolation. A blocking call is not a bug in itself; a blocking call on the loop is. Dropping the async keyword moved the same time.sleep onto a worker thread and restored the overlap, at the cost of consuming a thread from a bounded pool.
Async Correctness and Concurrency develops this properly, including the ceilings that bound each strategy. FastAPI async def vs def: Performance and When to Use Each is the decision guide for the keyword itself. Fixing Blocking Calls in Async FastAPI Routes covers finding the middle row in a codebase you did not write, which is harder than it sounds because the blocking call is usually several frames down inside a library. Running Sync Code in a Threadpool in FastAPI covers doing the third row deliberately for a single call rather than a whole endpoint, and Concurrent Requests with asyncio.gather in FastAPI covers the opposite problem — a handler that awaits several things in sequence when it could await them together.
Why this matters at scale. The cost is not paid by the endpoint that blocks. It is paid by every other request the worker was serving, including health checks, which is how a single slow report generator turns into an instance being cycled by a load balancer that believes the whole service is unhealthy.
The resource everything queues for
For most services the database is where the time goes, and it is also where the loop is most often blocked, because a synchronous driver has no way to yield. Async Database Sessions covers the wiring: one engine per process created in lifespan, one short-lived session per request handed out by a yield dependency.
Async SQLAlchemy Session per Request in FastAPI is the concrete implementation, and Transaction Management and Rollback in FastAPI settles who is allowed to commit — a question that determines whether a handler raising halfway through leaves a half-written record. Fixing asyncpg Connection Pool Exhaustion in FastAPI covers the failure everyone eventually hits, where requests stop failing individually and start queueing for a connection that never frees. Testing with Async Database Fixtures in FastAPI covers getting real database coverage without tests that contaminate each other.
Why this matters at scale. Pool size is the real concurrency limit of most services, and it is invisible until you cross it. Below the limit, added traffic costs nothing; above it, latency rises for everything at once because requests are now waiting to be allowed to start.
Work that should not be in the request
If the client does not need a result in order to receive its response, computing it inside the request is just latency the client pays for nothing. Background Task Processing covers moving it, and frames the central decision as a question about durability rather than convenience.
FastAPI BackgroundTasks vs Celery vs ARQ is the comparison, and Running ARQ Workers with FastAPI is the practical setup for the async-native option. Two guides cover what goes wrong: When FastAPI BackgroundTasks Silently Fails covers tasks that raise after the response has already gone out with a 200, which is a genuinely nasty class of bug because the client has no way to know; and Retry and Idempotency for FastAPI Background Tasks covers the consequence of any retry policy, which is that your task will eventually run twice and must be safe when it does.
Why this matters at scale. Deferred work changes the failure model rather than removing failure. A request that succeeded and a task that vanished looks like success from every angle except the customer's, so anything moved off the request path needs its own answer to "how would we know?".
Not doing the work at all
Caching is the most effective optimisation available and the one with the most ways to be subtly wrong. Caching Strategies covers the trade you are actually making, which is freshness for speed, and who bears the cost of the stale window.
Redis Response Caching in FastAPI covers the cache-aside pattern implemented as a dependency, which composes with everything else in this area. Cache Invalidation Patterns in FastAPI covers the hard half. Caching Dependency Results in FastAPI covers the layer above Redis — values worth holding for one request or for the process lifetime, which connects directly to the per-request caching described in Dependency Injection Strategies.
Why this matters at scale. A cache changes what your system does when it is under stress, and not always for the better. Capacity planning based on hit-rate assumptions is planning for the good case; the number worth knowing is what happens to your database the moment a large slice of the cache is unavailable.
Refusing work you cannot afford
Rate limiting exists so that one caller cannot consume capacity everyone else needs. Rate Limiting and Throttling covers the design as an ordered set of decisions, of which the key — what you count per — matters more than the algorithm and receives less thought.
FastAPI Rate Limiting with Redis and SlowAPI covers adopting an existing limiter, Per-User Token Bucket Throttling in FastAPI covers building one when you need behaviour the libraries do not offer, and Rate Limit Headers and 429 Responses in FastAPI covers the client-facing half — because a 429 with no Retry-After is an invitation to retry immediately, which converts a rate limit into a load amplifier.
Why this matters at scale. A limiter is the only mechanism in this area that protects you from traffic rather than making you faster at serving it. It is also the only one whose absence is invisible until the day it would have mattered.
Knowing what happened
Everything above becomes debuggable or undebuggable based on decisions made long before the incident. Observability and Tracing covers the three signals and what each is genuinely good at, which is a more useful framing than trying to make any one of them do everything.
The cheapest high-value step is a single identifier attached to every log line, which is Structured JSON Logging with Request IDs in FastAPI, built on the middleware from Implementing Custom Middleware for Request Tracing. Prometheus Metrics for FastAPI covers the aggregate view and, importantly, the cardinality mistakes that make a metrics backend expensive. Instrumenting FastAPI with OpenTelemetry covers distributed tracing. And Correlating Logs, Traces and Errors in FastAPI covers joining the three, which is what turns three tools into one investigation.
One thing worth checking early is whether your identifier survives the whole request. A ContextVar set in pure-ASGI middleware was traced through a yield dependency, a handler and two background tasks; the recorded record trail is:
$ GET /records
200 OK
{
"records": [
"middleware (entering): request_id=rid-001",
"dependency setup: request_id=rid-001",
"path operation: request_id=rid-001",
"background task: request_id=rid-001",
"background task (id passed in): request_id=rid-001",
"dependency teardown: request_id=rid-001",
"middleware (leaving): request_id=rid-001"
]
}
On FastAPI 0.139.2 the identifier does survive into background tasks under this arrangement, which is a happier result than the folklore suggests — but note the ordering, because it is not what most people would guess. Both background tasks ran before the yield dependency's teardown, and both ran before the middleware finished. A task that assumes its database session has already been committed and closed is making an assumption this transcript contradicts. Yield Dependencies and Cleanup Order in FastAPI covers that ordering in detail.
Why this matters at scale. Observability is the one capability you cannot add during the incident that requires it. Every other item in this area can be retrofitted under pressure; this one has to already be there.
Proving it before production does
The blocking handler in the first example is a bug that no unit test would catch, because unit tests do not run two requests at once. Testing FastAPI Applications covers exercising the application through the framework rather than around it, so that routing, dependency resolution, validation and serialization are all actually running.
TestClient vs httpx AsyncClient in FastAPI covers the choice between the two clients, which is really a choice about which event loop your test code runs on and is the source of most confusing async test failures. Testing Async FastAPI Endpoints with pytest-asyncio covers the configuration that makes async def tests run at all. Mocking External Services in FastAPI Tests covers substituting third parties, and pairs with Overriding Dependencies in FastAPI Tests — dependency overrides being the seam the architecture gave you specifically for this.
Why this matters at scale. Everything on this page is behaviour under concurrency, and behaviour under concurrency is what a test suite of sequential unit tests is structurally unable to observe. That gap is worth closing deliberately rather than discovering.
Cross-cutting trade-offs
| Concern | Simpler form | Scales better as | What it costs |
|---|---|---|---|
| Handler style | async def everywhere | Chosen per handler by what it calls | Auditing what your libraries do internally |
| Blocking calls | Leave them inline | Threadpool offload or an async client | A bounded pool that itself can saturate |
| Database access | Sync driver and session | Async engine, session per request | Async all the way down, or nothing |
| Deferred work | BackgroundTasks | Broker-backed queue | A broker, workers and their deploys |
| Retries | None | Retry with idempotency keys | Every task must be safe to run twice |
| Hot reads | Query every time | Cache with explicit invalidation | A stale window someone must accept |
| Abuse control | None | Limiter keyed on identity | Shared state, and legitimate users hitting it |
| Diagnostics | Unstructured logs | Correlated logs, traces and metrics | Instrumentation that must be maintained |
| Testing | Sequential unit tests | Through-the-framework async tests | Fixture and event-loop discipline |
The rows are not independent. A cache reduces pool pressure, a limiter protects the cache, and observability is how you discover which of the two you actually needed. The one that must come first is the top row, because a blocked loop makes the measurements produced by the bottom row meaningless.
Named anti-patterns
async def over a synchronous client. A handler declared async that calls requests, a sync database driver, or any library that was not written for asyncio. Root cause: async def reads as a performance annotation rather than a promise about what the body does. Symptom: throughput that does not improve with concurrency, and latency that rises for unrelated endpoints. Fix: an async client, an explicit threadpool offload, or a plain def handler — Fixing Blocking Calls in Async FastAPI Routes.
The session that outlives its request. A session stored on a module global or on app.state instead of yielded per request. Root cause: it removes the need to pass it around. Symptom: transactions from different requests interleaving, and errors that only reproduce under load. Fix: one short-lived session per request from a yield dependency, per Async Database Sessions.
Durable work in BackgroundTasks. Charging a card, provisioning an account or sending a receipt from an in-process background task. Root cause: it is one line and it works in every test. Symptom: every deploy silently drops whatever was in flight, and nothing anywhere records that it happened. Fix: a broker-backed queue for anything that must eventually complete.
Retrying without idempotency. Adding a retry policy to a task that was never designed to run twice. Root cause: retries are configuration and idempotency is design, so one is much easier to add than the other. Symptom: duplicate charges, duplicate emails, duplicate rows — appearing only when something upstream was already failing. Fix: an idempotency key checked before the side effect, per Retry and Idempotency for FastAPI Background Tasks.
Caching without an invalidation story. A TTL added to a slow endpoint with no plan for writes. Root cause: the cache is added to fix latency, and correctness is a separate concern nobody was assigned. Symptom: users updating a record and being shown the old value, intermittently, for reasons support cannot reproduce. Fix: decide the invalidation path at the same time as the cache — Cache Invalidation Patterns in FastAPI.
Unbounded fan-out. A handler that awaits a large collection of concurrent calls with no ceiling. Root cause: gather makes unbounded concurrency the shortest thing to write. Symptom: one request exhausting the pool or the downstream service's own rate limit, in the name of being fast. Fix: a semaphore or a chunked approach, per Concurrent Requests with asyncio.gather in FastAPI.
Logging narration instead of identifiers. Log lines that describe what the code is doing rather than recording what it decided and for whom. Root cause: logs are written while writing the code, when the context is obvious. Symptom: an incident where you have thousands of matching lines and no way to isolate the one request that mattered. Fix: structured fields and a request identifier on every line — Structured JSON Logging with Request IDs in FastAPI.
FAQ
Why does one slow endpoint make every endpoint slow?
Because a synchronous call inside an async def handler holds the event loop thread, and the loop is what serves every other in-flight request on that worker. Nothing yields until the blocking call returns, so requests that have nothing to do with the slow endpoint simply wait their turn.
If a function is not async, should I still declare the endpoint async def?
No. Declaring async def and then calling blocking code is the worst of both options, because it blocks the loop. A plain def endpoint is run in a worker thread by FastAPI, so the blocking call is isolated. Use async def only when the body genuinely awaits.
When is BackgroundTasks enough, and when do I need a real queue?BackgroundTasks runs in the same process after the response is sent, so the work disappears if the process restarts. That is acceptable for work you can afford to lose, such as a best-effort notification. Anything that must eventually happen — payments, provisioning, anything a customer paid for — needs a broker-backed queue.
What is the minimum useful observability setup for a FastAPI service? One request identifier generated in middleware and attached to every log line, plus structured JSON logs so those lines are queryable. That alone turns most incidents from guesswork into a search. Traces and metrics are the next additions, and both are far more useful once the identifier already exists.
Why do my async tests fail with an event loop error?
Usually because a fixture and a test are running on different event loops, or because a synchronous TestClient is used alongside async fixtures bound to your own loop. TestClient drives the app from its own portal thread while httpx AsyncClient runs it on the loop you are already in, and mixing the two models is what produces the error.
Where should a rate limiter sit relative to the application? As far out as you can put it while still being able to key on the right identity. An edge limiter protects the application from traffic it never has to parse; an in-application limiter can key on an authenticated user rather than an IP address. Most production setups end up running both, for different reasons.
Related
Read Async Correctness and Concurrency first regardless of what brought you here, because a blocked loop invalidates every other measurement you might take. Then Async Database Sessions, which is where most services spend most of their time.
Background Task Processing, Caching Strategies and Rate Limiting and Throttling are the three levers for handling more traffic — respectively by deferring work, avoiding it, and declining it. Observability and Tracing tells you which lever to pull, and Testing FastAPI Applications is how any of it gets verified before a customer does it for you.
The upstream areas are Core Architecture and Routing Patterns, which supplies the lifespan hook and middleware layer everything here plugs into, and Advanced Pydantic Validation and Serialization, which determines how much CPU each response spends before it reaches the wire.