FastAPI Slow or Hanging Under Load? Blocking async def Fix

One slow FastAPI request makes every other request wait, even a health check? Find the blocking call inside async def, then fix it with def, httpx or run_in_threadpool.

If one FastAPI endpoint is slow and everything else on the same worker freezes, a blocking call is almost always sitting inside an async def function. FastAPI runs async def endpoints directly on the event loop, so a call like requests.get() or time.sleep() stops every other request on that worker. Change the endpoint to a plain def, use an async client with await, or wrap only the blocking call in run_in_threadpool.

This bug is easy to ship because the code works in development. With one user, nothing waits. In production, with ten users at once, latency climbs, the health check times out, and CPU stays low. Here is how to confirm the cause and fix it.

Step 1: Recognise the Symptoms

Blocking the event loop has a clear pattern:

  • Latency grows with concurrent traffic, not with data size.
  • CPU on the server is low, because the process is waiting, not working.
  • Unrelated endpoints, even a /health route that returns {"status": "ok"}, time out while one slow request runs. One write-up describes exactly this.
  • A load balancer or platform marks the instance unhealthy and restarts it.

If your symptoms are a 502 behind gunicorn with Django, see Gunicorn WORKER TIMEOUT and 502 on Django. The cause here is similar but the fix is different.

Step 2: Understand How FastAPI Runs Your Code

FastAPI treats the two function types differently:

  • async def endpoints run on the event loop. The loop is single-threaded. It can only switch to another request when your code reaches an await.
  • Plain def endpoints are sent to a worker threadpool, so a blocking call ties up one thread, not the whole loop. The same source above notes this pool defaults to 40 threads, shared across the process.

So async def is not faster by itself. It is only faster when everything inside it is non-blocking and awaited. A cleanup that turns every def into async def can cause an outage, as this story describes.

Step 3: Find the Blocking Call

Look inside every async def for calls that wait without await:

import time
import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/report")
async def report():
    time.sleep(2)                                   # blocks the loop
    r = requests.get("https://api.example.com/x")   # blocks the loop
    return r.json()

Common offenders:

  • requests, urllib and other synchronous HTTP clients.
  • time.sleep().
  • A synchronous database driver or ORM session (psycopg2, pymysql, classic SQLAlchemy sessions).
  • Synchronous SDK calls to Stripe, an LLM provider, S3 (boto3) or an email service.
  • Heavy CPU work such as image resizing, PDF generation or large JSON parsing.

To confirm, send two requests at once to the suspect endpoint:

curl -s -o /dev/null -w "%{time_total}\n" localhost:8000/report &
curl -s -o /dev/null -w "%{time_total}\n" localhost:8000/health &
wait

If /health takes about as long as /report, the loop is blocked.

Step 4: Fix It, Option A: Use a Plain def

If the endpoint mostly does blocking work, remove async:

@app.get("/report")
def report():
    r = requests.get("https://api.example.com/x", timeout=10)
    return r.json()

FastAPI runs it in the threadpool. This is the smallest change and is often the right one for code that uses a sync database driver. Always set a timeout on outbound calls, because a threadpool can also fill up.

Step 5: Fix It, Option B: Go Fully Async

If the work is network I/O, use an async client and await it:

import httpx

@app.get("/report")
async def report():
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.get("https://api.example.com/x")
    return r.json()

Do the same for the database: use an async driver such as asyncpg, or SQLAlchemy's async engine, and await every query. Create the HTTP client once at startup, not on every request, if you call the same host often.

Step 6: Fix It, Option C: Offload Only the Blocking Part

When a library has no async version, keep the endpoint async and push the one blocking call to a thread:

from fastapi.concurrency import run_in_threadpool

@app.get("/report")
async def report():
    data = await run_in_threadpool(build_pdf_sync, report_id)
    return data

This still uses the shared threadpool. For heavy CPU work, threads will not help much in Python because of the GIL. Move that work to a background worker such as Celery, RQ or ARQ and return a job ID instead. Also check that you did not forget an await on a coroutine, which leaves work undone or stuck.

Step 7: Add Guards So It Does Not Come Back

  • Set timeouts on every outbound call (httpx, database, LLM SDKs). An LLM call with no timeout can hold a request for a long time. See OpenAI API 429 and Timeout Errors in Production.
  • Run more than one worker process. For example uvicorn app.main:app --workers 4, or gunicorn with uvicorn.workers.UvicornWorker. One blocked worker then does not stop the whole service.
  • Alert on p95 latency of /health as well as of real endpoints. A health check that slows down is an early sign of a blocked loop.
  • Review any pull request that adds async def and ask what is awaited inside it. The same pattern in Node.js is covered in Node.js Event Loop Blocked.

How Relia Helps

Blocking bugs show up late, as slow responses and failed health checks after a deploy. Relia watches the APIs, database and servers of a live product, joins related signals such as a deploy, a slow request and users hitting errors into one incident explained in plain words, and shows who was affected. After a deploy it checks whether the fix worked. Relia has a Python package, and you can add uptime checks on key pages. There is a Free plan, and a live demo at app.tryrelia.com/demo.

FAQ

Why is my FastAPI app slow when multiple requests come in?

A blocking call inside an async def endpoint stops the event loop, so requests queue behind it. Use def, an async client, or run_in_threadpool.

Should I use def or async def in FastAPI?

Use async def only when every call inside it is awaited and non-blocking. Use def when you call sync libraries. FastAPI runs def endpoints in a threadpool.

How many threads does FastAPI use for def endpoints?

The pool defaults to 40 threads per process, according to this article. If many requests block at once, they queue after that.

Why does my health check time out when one endpoint is slow?

They share the same event loop. If a blocking call holds the loop, the health route cannot respond until it finishes.

Does adding more uvicorn workers fix it?

It reduces the damage, because other processes keep serving. It does not remove the blocking call, so fix the code too.

All guides