You deploy your FastAPI app behind Gunicorn with Uvicorn workers, and everything looks fine — until your logs fill up with [CRITICAL] WORKER TIMEOUT and requests start dropping under real traffic. It never happens locally, which makes it maddening to chase down.

Quick Answer: WORKER TIMEOUT in a Gunicorn + Uvicorn deployment means a worker process didn’t respond to the master’s heartbeat within --timeout seconds, so it got killed and restarted. The three usual culprits:

  1. Blocking, synchronous code inside an async def route — it freezes the entire event loop, so even unrelated requests stop getting processed
  2. A genuinely slow request — a big export, a slow upstream API, a report job — that legitimately takes longer than your configured timeout
  3. A worker that’s silently leaking memory and eventually gets killed by the OS’s OOM killer, which looks similar in the logs but has a completely different fix

Figuring out which one you’re dealing with is the difference between a five-minute fix and hours of chasing ghosts.

How Uvicorn Workers Actually Work

To make sense of the timeout, it helps to know what’s actually running. In a typical production setup you’re not running uvicorn directly — you’re running Gunicorn as a process manager, with Uvicorn’s UvicornWorker class handling the actual ASGI serving inside each worker process:

gunicorn app.main:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --timeout 30 \
  --bind 0.0.0.0:8000

Gunicorn’s master process spawns N worker processes and periodically pings each one to make sure it’s still alive. Each worker, in turn, runs its own asyncio event loop and handles many concurrent requests on a single thread within that loop. That single-threaded-per-worker design is exactly why one bad request can take down every other request in that same worker — there’s no OS-level preemption between them the way there would be with separate threads.

If a worker doesn’t check in with the master within --timeout seconds (30 by default), Gunicorn assumes it’s hung, sends it a SIGKILL, and spins up a replacement. That’s the [CRITICAL] WORKER TIMEOUT line you see in your logs. It’s not really an exception in your code — it’s the master process giving up on a worker it thinks is dead.

That distinction matters. You won’t find a traceback for a WORKER TIMEOUT in your application logs, because the process was killed from the outside, mid-execution. Whatever request triggered it just vanishes.

Common Pitfalls

Assuming async def makes everything non-blocking. Marking a route async def doesn’t magically make the code inside it asynchronous — it just means the function can yield control with await. If you never await anything, or you call a blocking library function, you’ve built a route that blocks the whole worker exactly as badly as sync code would.

Setting the timeout too low for legitimately slow work. Not every slow request is a bug. If you have an endpoint that generates a PDF report or proxies a slow third-party API, a 30-second default timeout might just be too aggressive for that one route.

Confusing OOM kills with timeout kills. A memory leak that grows over hours will eventually get the process killed by the Linux OOM killer, not by Gunicorn’s timeout logic. The symptom — a worker disappearing and restarting — looks similar in a dashboard, but dmesg or your container orchestrator’s event log will show Killed or OOMKilled instead of WORKER TIMEOUT, and the fix is completely different.

Running too few workers for your concurrency. Even with fully non-blocking code, if every worker’s event loop is juggling more concurrent requests than it can realistically finish within the timeout window, some of them will pile up and eventually trip the limit.

Real-World Examples

Example 1: A blocking database call under load

This is the single most common cause. The route looks async, but it’s calling a synchronous driver underneath:

# ❌ Before — psycopg2 is a sync driver, called directly in an async route
import psycopg2
from fastapi import FastAPI

app = FastAPI()

@app.get("/reports/{report_id}")
async def get_report(report_id: int):
    conn = psycopg2.connect(DATABASE_URL)
    cur = conn.cursor()
    cur.execute("SELECT * FROM reports WHERE id = %s", (report_id,))
    return cur.fetchone()

Under light load this “works” — one query, one response, nobody notices. Under real traffic, every one of those blocking cur.execute() calls freezes the event loop for its full duration. Ten concurrent requests to this endpoint serialize themselves, and if enough of them stack up, the worker stops responding to Gunicorn’s heartbeat entirely.

# ✅ After — use an async driver, or push the blocking call to a thread
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool
import psycopg2

app = FastAPI()

def fetch_report_sync(report_id: int):
    conn = psycopg2.connect(DATABASE_URL)
    cur = conn.cursor()
    cur.execute("SELECT * FROM reports WHERE id = %s", (report_id,))
    return cur.fetchone()

@app.get("/reports/{report_id}")
async def get_report(report_id: int):
    return await run_in_threadpool(fetch_report_sync, report_id)

run_in_threadpool moves the blocking call off the event loop and onto a worker thread, so other requests keep flowing while it waits on the database. The real fix, long term, is switching to an async driver like asyncpg or SQLAlchemy’s async engine — but the thread pool buys you breathing room immediately. This is the same class of bug covered in more depth in our guide on mixing sync and async code in FastAPI.

Example 2: A legitimately slow endpoint

Sometimes there’s no bug at all — the timeout is just wrong for the work being done:

# ❌ Before — a 45-second export job, but the server timeout is 30s
gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --timeout 30
# ✅ After — raise the timeout for the deployment, and move truly long
# jobs off the request/response cycle entirely
gunicorn app.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --timeout 90

Bumping --timeout is a legitimate short-term fix, but it doesn’t scale — a client is still sitting there holding an open connection for 90 seconds, and one slow endpoint can eat up worker capacity that the rest of your app needs. For anything that takes more than a few seconds, the better pattern is to kick off the work asynchronously and let the client poll for the result:

from fastapi import FastAPI, BackgroundTasks
import uuid

app = FastAPI()
job_store: dict[str, str] = {}

def generate_export(job_id: str):
    # ... the actual slow work happens here, off the request path
    job_store[job_id] = "done"

@app.post("/exports")
async def start_export(background_tasks: BackgroundTasks):
    job_id = str(uuid.uuid4())
    job_store[job_id] = "pending"
    background_tasks.add_task(generate_export, job_id)
    return {"job_id": job_id}

@app.get("/exports/{job_id}")
async def check_export(job_id: str):
    return {"status": job_store.get(job_id, "unknown")}

For anything heavier than a quick background task, reach for a real task queue like Celery or RQ instead — BackgroundTasks runs in the same process and doesn’t survive a worker restart. We cover the tradeoffs of FastAPI’s built-in background tasks in our post on background task failures in FastAPI.

Example 3: A slow memory leak masquerading as a timeout

This one’s the trickiest because the log line looks identical, but the cause is completely different:

# ❌ Before — a module-level cache that grows without bound
_response_cache = {}

@app.get("/lookup/{key}")
async def lookup(key: str):
    if key not in _response_cache:
        _response_cache[key] = expensive_computation(key)
    return _response_cache[key]

If key has high cardinality (user IDs, request IDs, timestamps), this cache grows forever. After a few hours in production, the worker’s memory footprint balloons until the OS kills it — and depending on your process supervisor, that can trigger Gunicorn to log a timeout-looking message as it tries and fails to restart cleanly.

# ✅ After — bound the cache size, or use a proper TTL cache
from cachetools import TTLCache

_response_cache = TTLCache(maxsize=1000, ttl=300)

@app.get("/lookup/{key}")
async def lookup(key: str):
    if key not in _response_cache:
        _response_cache[key] = expensive_computation(key)
    return _response_cache[key]

Check dmesg | grep -i kill or your container platform’s event history before assuming a WORKER TIMEOUT is a code-blocking issue — an OOMKilled event points you toward memory profiling instead of event-loop debugging, and chasing the wrong one wastes real time.

Advanced Tips

Use --max-requests to recycle workers proactively. Setting --max-requests 1000 --max-requests-jitter 50 on Gunicorn restarts each worker after roughly a thousand requests. It’s a blunt instrument, but it caps the damage from slow leaks you haven’t found yet, and the jitter prevents all your workers from restarting at the same instant.

Log before you block, not after. Because a killed worker never gets to run its own exception handlers, add request-level timing middleware that logs slow requests as they’re happening rather than trying to reconstruct what happened after the fact:

import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware

logger = logging.getLogger("slow_requests")

class SlowRequestLogger(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        start = time.monotonic()
        response = await call_next(request)
        duration = time.monotonic() - start
        if duration > 5:
            logger.warning("Slow request: %s took %.2fs", request.url.path, duration)
        return response

app.add_middleware(SlowRequestLogger)

Separate your timeout tiers. A single global --timeout value forces every route into the same box. If you genuinely need one endpoint to run longer, put it behind a separate deployment or a reverse-proxy route with its own timeout, rather than loosening the limit for your entire app.

Watch worker restart counts, not just error rates. A dashboard that only tracks 5xx responses will miss WORKER TIMEOUT kills entirely, since the client often just sees a dropped connection. Track Gunicorn’s own log output or your process supervisor’s restart counter as a first-class metric.

Key Takeaways

  • WORKER TIMEOUT means Gunicorn’s master killed a worker that didn’t respond to its heartbeat — it’s not an exception you can catch in your own code
  • The most common cause is blocking, synchronous code running inside an async def route, which freezes the entire worker’s event loop
  • Genuinely slow work belongs in a background task or task queue, not a longer --timeout
  • OOM kills from memory leaks can look identical to timeout kills in a dashboard — check dmesg or your platform’s event log before you start debugging the wrong thing
  • Proactive worker recycling (--max-requests) and slow-request logging catch problems before they turn into full outages

When you do catch a real traceback — from a slow-request log, an OOM event, or an exception that happens right before a kill — use Debugly’s trace formatter to quickly parse and analyze Python tracebacks so you can pinpoint exactly which call is stalling your worker.