Diagnosing Silent Request Timeouts in FastAPI Behind a Reverse Proxy

May 21, 2026 7 min read 43 views
Flat illustration of a disconnected chain link between a server and browser representing a broken proxy connection timeout

Your FastAPI endpoint returns a response in under 50ms during local development. You deploy it behind nginx or a cloud load balancer, and suddenly some requests just disappear β€” no 500, no traceback, just a client that eventually gives up waiting. The logs show nothing useful.

Silent timeouts are one of the most frustrating classes of production bugs because every layer claims it's fine. This guide walks you through finding the actual culprit and patching it properly.

What you'll learn

  • How to map the timeout chain from client through proxy to your ASGI app
  • The most common nginx and uvicorn misconfigurations that cause silent drops
  • How to add structured timeout logging so you can see exactly where a request dies
  • How to tune keepalive, read, and send timeout settings across each layer
  • How to write a minimal reproducible test to confirm your fix worked

Prerequisites

You should be comfortable with basic FastAPI and have at least one deployment where an ASGI server (uvicorn or gunicorn+uvicorn workers) sits behind a reverse proxy. The examples use nginx, but the concepts apply equally to Caddy, Traefik, or AWS ALB/ELB.

Understanding the Timeout Chain

Every request travels through at least three hops: the client, the reverse proxy, and the ASGI worker. Each hop has its own timeout clock, and they rarely agree on the same value.

When a timeout fires, the layer that triggered it may close the connection silently rather than sending an error response. The layer downstream then sees an abrupt disconnect and may log nothing at all β€” or log a generic connection reset. That's why your nginx error.log looks clean even though clients are experiencing failures.

Draw this chain out explicitly for your own deployment:

Client (browser / HTTP client)
  └─> Load balancer or CDN (optional)
        └─> nginx / reverse proxy
              └─> uvicorn / gunicorn worker
                    └─> Your FastAPI route handler

A timeout can fire at any of these boundaries. Your job is to narrow it down to one.

Step 1 β€” Reproduce the Timeout Reliably

You cannot diagnose what you cannot reproduce. The simplest approach is to add a route that sleeps for a controllable duration, then hammer it with increasing sleep values until the timeout fires.

import asyncio
from fastapi import FastAPI

app = FastAPI()

@app.get("/slow")
async def slow_endpoint(seconds: float = 5.0):
    await asyncio.sleep(seconds)
    return {"slept": seconds}

Hit it with a value you know should succeed, then push it higher by one second at a time. Record the exact duration at which the response disappears. That number is your first clue.

Use curl with verbose output so you can see when the TCP connection drops versus when a proper response arrives:

curl -v --max-time 60 "https://yourapp.example.com/slow?seconds=25"

If the connection resets cleanly at, say, exactly 30 seconds, you're looking at a proxy-level timeout. If it resets at an odd duration, check your ASGI worker configuration.

Step 2 β€” Check nginx Timeout Directives

nginx has several independent timeout settings, and they interact in non-obvious ways. The one that catches people most often is proxy_read_timeout.

proxy_read_timeout controls how long nginx will wait for a single read from the upstream (your uvicorn process). Its default is 60 seconds. If your route takes 61 seconds for any reason β€” a slow database query, a third-party API call, a large file operation β€” nginx silently closes the connection before uvicorn has finished writing the response.

http {
    proxy_connect_timeout  10s;
    proxy_send_timeout     30s;
    proxy_read_timeout     90s;   # increase this for slow routes
    keepalive_timeout      75s;

    server {
        location /api/ {
            proxy_pass http://127.0.0.1:8000;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
        }
    }
}

The key detail: proxy_read_timeout resets each time nginx receives a chunk of data from upstream, not just once at the start. So streaming endpoints with periodic writes will behave differently from endpoints that buffer the entire response before sending.

Other nginx settings worth auditing

  • client_body_timeout β€” how long to wait between successive reads of the request body. Hits large file uploads hard.
  • send_timeout β€” how long nginx waits between successive writes to the client. Can cut off downloads if the client is slow.
  • keepalive_timeout β€” idle time before nginx closes a persistent connection. Should be slightly longer than your load balancer's idle timeout if you're behind one.

Step 3 β€” Check Uvicorn and Gunicorn Worker Timeouts

If nginx's timeout is generous but requests still vanish, look at the ASGI server layer. Gunicorn has a --timeout flag that kills and restarts a worker if it does not respond within that many seconds. The default is 30 seconds.

gunicorn app.main:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --timeout 120 \
  --graceful-timeout 30

When gunicorn kills a worker mid-request, the client sees a connection reset. The gunicorn log will show a WORKER TIMEOUT line, but only if you're watching it. Make sure your log aggregation is actually capturing gunicorn's stderr, not just uvicorn's access log.

Uvicorn itself, when run directly (not under gunicorn), does not enforce a per-request timeout. That means if you're using uvicorn app.main:app in production and timeouts are still firing, the culprit is upstream of uvicorn.

Step 4 β€” Add Middleware-Level Timeout Logging

Once you've narrowed down the layer, you want structured evidence. A small piece of middleware records how long each request takes and logs any that exceed your threshold. This is far more useful than guessing from access logs.

import time
import logging
from fastapi import FastAPI, Request

logger = logging.getLogger("timeout_watch")
SLOW_REQUEST_THRESHOLD = 20.0  # seconds

app = FastAPI()

@app.middleware("http")
async def log_slow_requests(request: Request, call_next):
    start = time.perf_counter()
    try:
        response = await call_next(request)
    except Exception as exc:
        elapsed = time.perf_counter() - start
        logger.error(
            "Request failed after %.2fs: %s %s",
            elapsed, request.method, request.url.path,
            exc_info=exc,
        )
        raise
    elapsed = time.perf_counter() - start
    if elapsed > SLOW_REQUEST_THRESHOLD:
        logger.warning(
            "Slow request: %.2fs %s %s",
            elapsed, request.method, request.url.path,
        )
    return response

This middleware fires after the route handler completes. If you see no log entry at all for a request that the client says timed out, the timeout fired at the proxy level before the response left uvicorn. If you see the log entry with a long duration, the route itself is slow and you need to fix the handler or push the proxy timeout higher.

Step 5 β€” Handle Long-Running Tasks the Right Way

A timeout that fires at exactly 30 or 60 seconds is usually a configuration mismatch. A timeout that fires at unpredictable intervals on specific endpoints is often a blocking call inside an async route handler.

Running a CPU-bound or blocking I/O call directly in an async function blocks the entire event loop. Every other request queues behind it. The route appears slow, and nginx eventually gives up.

import asyncio
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI

executor = ThreadPoolExecutor()
app = FastAPI()

def blocking_work(data: str) -> str:
    # synchronous database call, subprocess, etc.
    import time; time.sleep(5)
    return data.upper()

@app.get("/process")
async def process(data: str = "hello"):
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(executor, blocking_work, data)
    return {"result": result}

Offloading blocking work to a thread pool releases the event loop, so other requests stay responsive. For truly heavy processing, consider a task queue (Celery, ARQ, or similar) and return a job ID that the client polls rather than holding the HTTP connection open.

Step 6 β€” Cloud Load Balancer Idle Timeouts

If you're on AWS, GCP, or Azure, there's an additional layer between the public internet and your nginx instance. AWS ALB has a default idle timeout of 60 seconds. If your nginx keepalive_timeout is lower than the ALB idle timeout, the ALB may try to reuse a connection that nginx already closed β€” causing a reset that looks exactly like a request timeout.

The fix: set your nginx keepalive_timeout a few seconds higher than the load balancer's idle timeout, and make sure proxy_http_version 1.1 and proxy_set_header Connection "" are set so nginx uses persistent connections to the upstream.

upstream fastapi_backend {
    server 127.0.0.1:8000;
    keepalive 32;  # keep up to 32 idle upstream connections
}

server {
    location /api/ {
        proxy_pass http://fastapi_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

Common Pitfalls

  • Assuming the access log tells the full story. nginx's access log records the request only after it's fully processed. A timed-out request may not appear there at all, or it may appear with a 499 status (client closed request) that looks like the client's fault.
  • Setting proxy_read_timeout too high as a blanket fix. Pushing it to 300 seconds hides slow queries rather than fixing them. Use it as a ceiling, then find and optimize the actual slow path.
  • Forgetting WebSocket upgrade paths. If your app also serves WebSockets, nginx's proxy_read_timeout applies to those too. Long-lived WebSocket connections need a much higher value, usually set in a separate location block.
  • Running uvicorn with a single worker in production. One blocking request will freeze the entire process. Always use at least two workers, or run under gunicorn with multiple uvicorn workers.
  • Not testing after a deployment pipeline change. CI environments often strip or reset timeout config from your nginx template. The timeout that worked last month may have changed when someone updated the deployment script.

Wrapping Up

Silent timeouts are a layered problem and they require a layered investigation. Work from the outside in: reproduce the timeout at a known duration, check the proxy first, then the ASGI server, then the route handler itself.

Here are the concrete actions to take right now:

  1. Add the /slow diagnostic endpoint to your staging environment and find the exact second at which requests drop.
  2. Audit your nginx config for proxy_read_timeout, proxy_send_timeout, and keepalive_timeout β€” make sure they form a coherent chain with your load balancer's idle timeout.
  3. Check gunicorn logs for WORKER TIMEOUT lines and increase --timeout if legitimate long-running routes need more time.
  4. Add the slow-request middleware to your FastAPI app so you have visibility into handler duration in production logs.
  5. Audit any async route handlers for blocking calls and move them to run_in_executor or a task queue.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.