← Knowledge hub
Tutorialby Uptime Room· September 24, 2026· 5 min read

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

Should a FastAPI health check use async def or def?

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.

How do I stop FastAPI logging health check 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.

What status code should a FastAPI health check return?

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.

Should the health route check Redis and the database?

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.

How often should something call the health endpoint?

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