FastAPI Health Check: Async Routes That Tell the Truth
A FastAPI health check that blocks the event loop hurts more than it helps. Here are two async routes, concurrent dependency probes and a quiet access log.
A FastAPI health check has one problem the framework guides skip. FastAPI runs on an event loop, so a route that blocks stalls every other request on that worker. The endpoint meant to prove your service is healthy is the one most likely to make it unhealthy. Here is the version that does not.
What should a FastAPI health check verify?
Two different things, on two different routes. A liveness route proves the process is up and serving HTTP, and touches nothing else. A readiness route proves this worker can do its job, so it checks the database and any cache or broker it genuinely needs.
Splitting them is not pedantry. A liveness route that queries Postgres will fail during a database blip, your orchestrator will restart a healthy container, and a slow dependency becomes a restart loop. Keep liveness free of dependencies and the failure stays where it started.
The two routes
Start here and add nothing until something forces you to.
from fastapi import APIRouter, Response
router = APIRouter(tags=["ops"])
@router.get("/livez", include_in_schema=False)
async def livez() -> dict[str, str]:
return {"status": "up"}
@router.get("/readyz", include_in_schema=False)
async def readyz(response: Response) -> dict:
report = await run_checks()
response.status_code = 200 if report["status"] != "down" else 503
return report
include_in_schema=False keeps both out of your OpenAPI page, where they are noise for API consumers. Setting the code on the injected Response lets you return a body and a status together. That matters, because a load balancer reads the code and never parses your JSON.
The path names are a convention rather than a rule. Kubernetes uses livez and readyz internally, Spring Boot uses /actuator/health, and plenty of teams just use /health and /ready. Pick a pair, write them down next to the monitor configuration, and never move them, because every caller you forgot about is hard-coded to the old path.
Three states are worth the small extra effort. Up means everything required is working, down means this worker cannot serve, and degraded means it can serve with something missing. A dead cache is usually degraded rather than down, so the worker stays in the pool and someone still finds out.
Checking dependencies without blocking the loop
This is where a FastAPI health check goes wrong. A route declared with async def runs directly on the event loop, so any synchronous driver call inside it blocks every other coroutine on that worker. Use async drivers, run the checks concurrently, and cap each one with a timeout.
import asyncio, time
async def _probe(name: str, coro) -> dict:
started = time.perf_counter()
try:
await asyncio.wait_for(coro, timeout=1.5)
status, error = "up", None
except Exception as exc:
status, error = "down", type(exc).__name__
return {"name": name, "status": status,
"latency_ms": round((time.perf_counter() - started) * 1000, 1),
"error": error}
async def run_checks() -> dict:
checks = await asyncio.gather(
_probe("db", db_pool.fetchval("SELECT 1")),
_probe("cache", redis.ping()),
)
required = {"db"}
failed = {c["name"] for c in checks if c["status"] == "down"}
status = "down" if failed & required else ("degraded" if failed else "up")
return {"status": status, "checks": checks}
asyncio.wait_for is the important line. Without it the readiness route inherits whatever timeout the driver has, which is often none. A hanging database then turns into a hanging health route, and a caller waiting thirty seconds is worse off than one that got a fast 503.
The required set is the other decision in that snippet. Only db sits in it, so a dead cache produces degraded and keeps the worker in the pool. Add a name to that set only when the worker truly cannot answer requests without it.
If you are stuck with a synchronous driver, declare the route with plain def instead of async def. FastAPI then runs it in an external threadpool rather than on the loop, which is slower but does not block anything else.
Cache the verdict
Deep checks cost something every time they run. Four probes at a one-second cadence plus an orchestrator plus a load balancer adds up to real traffic against the database you are testing.
_cache: dict = {"at": 0.0, "report": None}
async def cached_report(ttl: float = 5.0) -> dict:
now = time.monotonic()
if _cache["report"] is None or now - _cache["at"] > ttl:
_cache["report"] = await run_checks()
_cache["at"] = now
return _cache["report"]
Five seconds is a reasonable default. We tested a five second window against a 30 second external interval and lost nothing in detection time, because the probe cadence is the wider of the two. Shorten the window only if something genuinely polls faster than that.
Keeping the probe out of your access log
Uvicorn logs every request it serves. A health route polled once a second writes tens of thousands of lines a day, and they drown the requests you wanted to read. Filter them at the logging layer rather than turning the access log off.
import logging
class SkipOps(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
return "/livez" not in msg and "/readyz" not in msg
logging.getLogger("uvicorn.access").addFilter(SkipOps())
Keep the error log intact. If the readiness route starts returning 503 you want to hear about it, and a filter on the access logger leaves exception logging untouched.
Container and orchestrator wiring
Point the container probe at the cheap route. Docker's HEALTHCHECK instruction marks the container unhealthy after the configured number of consecutive failures.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; \
sys.exit(0 if urllib.request.urlopen('http://localhost:8000/livez').status==200 else 1)"
Use --start-period generously. A FastAPI app that builds a connection pool in a lifespan handler is not ready the instant the port opens. Without a start period, the container gets killed while it is still coming up. The same split applies in a cluster, which is covered in more depth under Docker health checks.
Watching the public URL from outside
Every check above runs inside your own network. All of them stay green while DNS, TLS or the proxy in front of the app is broken. Only a probe from outside walks the path a user walks.
curl -X POST https://uptimeroom.com/api/v1/monitors \
-H "Authorization: Bearer urm_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "fastapi readyz",
"type": "keyword",
"target": "https://api.example.com/readyz",
"keyword": "\"status\":\"up\"",
"intervalSec": 60,
"regions": ["london", "bangalore"],
"timeoutMs": 5000
}'
Matching the body catches the case a status check misses, which is a route returning 200 while reporting itself down. Pick two of the six probe regions on offer as of September 2026, ideally on separate continents. One honest caveat applies. Event-loop lag and worker saturation are in-process numbers, and reading them needs something running inside the application rather than an outside probe. Every region code and its egress address sits on the probe IPs page.
Common mistakes
A blocking call inside an async def route. One synchronous psycopg call on the loop freezes every concurrent request on that worker. Use the async driver or move the route to plain def.
Checking third-party APIs. Your app is not unready because a payment provider is slow. That belongs on a dashboard, not in a readiness verdict that empties your pool.
Returning 200 with a failure inside. Nothing in the path parses JSON. The status code is the contract, and the design reasoning behind it is in health check endpoint design.
Never exercising the failure path. Stop Redis in staging and confirm you see degraded rather than a 500. An unhandled exception in the handler is the usual cause, and it makes the route useless exactly when it matters.
Build it in that order and the whole thing is roughly forty lines. Two routes, concurrent checks with timeouts, a short cache, and a log filter. Then point something outside your network at the public URL, because a FastAPI health check nobody watches is just a route. Two regions at a one-minute interval come with the free plan.
FAQ
Use async def when every dependency call inside it is awaitable, because the route then runs on the event loop with no thread overhead. If you are stuck with a synchronous driver, declare the route with plain def instead. FastAPI runs those in an external threadpool, which is slower but keeps a blocking call from stalling other requests.
Add a logging filter to the uvicorn.access logger that drops records mentioning your health paths. That removes the noise without disabling access logging for real traffic, and it leaves the error logger untouched, so a readiness route that starts failing still shows up. Turning the access log off entirely costs you far more than it saves.
Return 200 when the worker can serve and 503 when it cannot. Load balancers and orchestrators act on the status code and never parse the response body, so a 200 carrying a down status is read as healthy. Set the code on the injected Response object so you can return a detailed body alongside it.
The readiness route should check anything the worker genuinely needs to serve its main job, which usually means the database. Treat a dead cache as degraded rather than down, since the worker can still answer requests slowly. Keep the liveness route free of every dependency, or a database blip will trigger container restarts.
Container and orchestrator probes commonly run every 10 to 30 seconds, and an external uptime check every 30 to 60 seconds. Combined, that is enough traffic to matter if each call touches the database. Cache the verdict for a few seconds and serve the cached copy, which costs nothing in detection time.
Related reading
- Health Check Endpoint Design: What It Should Return - A health check endpoint answers one question for one caller. Here is how to pick the caller, choose 200 or 503, and keep the wrong things out of the body.
- Docker Health Check: Syntax, Compose, and Gotchas - A docker health check tells the daemon whether your container can still serve. Here is the syntax, the Compose ordering trick, and what it cannot see.
- Django Health Check: Endpoint, Probes and Alerts - A django health check should verify the database, cache and broker, answer 503 when one is gone, and be watched from outside your own cluster.