Fix Django 504 Gateway Timeout Behind Nginx

Diagnose Django 504 Gateway Timeout errors behind Nginx: find the slow layer, align proxy and Gunicorn timeouts, fix slow views, and offload long work.

Problem statement

Users see 504 Gateway Timeout from Nginx on some Django pages. A 504 means Nginx successfully connected to the upstream (Gunicorn, uWSGI, or Uvicorn) but did not receive a response within its timeout. Django may still be working on the request, may be stuck, or may never have received it because every worker was busy.

It is closely related to, but different from:

  • 502 Bad Gateway: Nginx could not connect, or the upstream closed the connection without a valid response (for example a worker killed by Gunicorn's own timeout)
  • Gunicorn WORKER TIMEOUT: Gunicorn killed a worker that was silent too long; Nginx then usually logs a 502
  • Cloudflare 524 or load balancer 504: the same kind of timeout, one hop further out

The fix is rarely "raise every timeout". Longer timeouts hide slow code, keep workers occupied, and make the next request more likely to queue and time out too. This guide finds which layer is slow and why, then fixes the cause.

Timeouts along the request pathClientwaitsNginxproxy_read_timeoutGunicorn--timeoutSlow workquery or API callSet inner timeouts shorter than outer ones so the failure is logged where it happens.

Quick answer

  1. Confirm the 504 comes from Nginx: look for upstream timed out (110: Connection timed out) while reading response header from upstream in the Nginx error log.
  2. Identify affected URLs and timing from the access log with $request_time and $upstream_response_time.
  3. Check whether all workers were busy (queueing) or one endpoint is slow.
  4. For slow endpoints, profile queries and external calls; fix N+1 queries, add indexes, set timeouts on HTTP clients.
  5. Move legitimately long work (exports, imports, report generation) to background tasks.
  6. Align timeouts from the outside in: load balancer ≥ Nginx proxy_read_timeout ≥ Gunicorn --timeout.
  7. Verify with timed requests and a drop in 504 counts.

Step-by-step solution

1. Confirm where the timeout happens

sudo grep "upstream timed out" /var/log/nginx/error.log | tail -n 5
every layer has a timeoutCDNoften 100 sLoad balanceridle timeout, e.g. 60 sNginx proxy_read_timeoutdefault 60 sGunicorn --timeoutdefault 30 s, kills the workerThe layer that returns the 504 is the one that gave up firstdjango-deployment.com
The error page style usually tells you which layer produced it.

Typical line:

upstream timed out (110: Connection timed out) while reading response header from upstream,
client: 203.0.113.7, server: www.example.com, request: "GET /reports/annual/ HTTP/1.1",
upstream: "http://unix:/run/myapp/gunicorn.sock:/reports/annual/"

"while reading response header" means the connection succeeded and Nginx waited for the response for proxy_read_timeout seconds (60 by default). If you instead see connect() failed, that is a 502 problem.

If a CDN or load balancer sits in front, check whether its timeout is shorter than Nginx's. Then the client sees the outer layer's error even though Nginx is still waiting.

2. Log upstream timings

Add timing fields to the Nginx access log so you can see where time is spent:

log_format timed '$remote_addr - [$time_local] "$request" $status '
                 'rt=$request_time urt=$upstream_response_time uct=$upstream_connect_time';

access_log /var/log/nginx/access.log timed;

Reload Nginx, then list the slowest requests:

sudo awk '{for (i=1;i<=NF;i++) if ($i ~ /^rt=/) {split($i,a,"="); print a[2], $0}}' /var/log/nginx/access.log \
  | sort -rn | head -20

Look at the pattern:

  • One or a few URLs consistently near the timeout: a slow endpoint.
  • Many unrelated URLs timing out at the same moment: worker starvation, database locks, or host resource exhaustion.
  • High uct (connect time): the upstream accept queue is backed up.

3. Check for worker starvation

Every sync Gunicorn worker handles one request at a time. If three workers are all stuck on 50-second report requests, a fourth request for a fast page waits in the queue and may also hit the Nginx timeout.

slow requests starve fast onesExport view called4 times, 90 s eachAll 4 workers busyno free slotHome page requestwaits in the queueNginx gives up60 s: 504Fixmove exports to a taskThe 504 appears on pages that are not slow at alldjango-deployment.com
Upstream response time in the Nginx log shows the queueing.
ps -o pid,etimes,pcpu,rss,cmd -C gunicorn
sudo journalctl -u gunicorn --since "30 min ago" | grep -E "WORKER TIMEOUT|Booting worker"

Count how many workers you have versus how many slow requests arrive concurrently. If slow requests regularly occupy most workers, capacity is the immediate problem, but the fix is still to make those requests fast or asynchronous.

4. Find why an endpoint is slow

Common causes, in rough order of frequency:

Too many or slow database queries. Log query counts and time for the endpoint in staging with production-like data, or use django-debug-toolbar locally. In production, enable PostgreSQL slow query logging:

ALTER SYSTEM SET log_min_duration_statement = '500ms';
SELECT pg_reload_conf();

Database locks. A request waiting for a row locked by another transaction looks exactly like a slow query:

SELECT pid, now() - query_start AS waiting, wait_event_type, wait_event, left(query, 80)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

External HTTP calls without timeouts. A call to a payment, geocoding, or partner API with no timeout can wait far longer than your proxy:

import requests

response = requests.get("https://api.partner.example/v1/rates", timeout=(3, 10))

The tuple sets a 3-second connect timeout and a 10-second read timeout.

Large responses built in memory. CSV or PDF exports that load every row before writing the first byte.

Cold caches. An expensive page that is normally cached, recomputed by many requests at once after a deploy or cache flush.

5. Fix slow queries

For N+1 query patterns, fetch related objects in bulk:

orders = (
    Order.objects.filter(created_at__year=2026)
    .select_related("customer")
    .prefetch_related("items__product")
)

For filters on unindexed columns, add an index through a migration:

class Order(models.Model):
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField()

    class Meta:
        indexes = [models.Index(fields=["status", "created_at"])]

Verify the improvement with EXPLAIN ANALYZE on the generated SQL (print(queryset.query) shows it).

6. Move long work out of the request

If an operation genuinely takes minutes, do not serve it synchronously. Return quickly and do the work in a task queue:

# reports/views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST

from .models import ReportJob
from .tasks import build_report


@require_POST
def start_report(request):
    job = ReportJob.objects.create(user=request.user, status="queued")
    build_report.delay(job.pk)
    return JsonResponse({"job_id": job.pk, "status_url": f"/reports/jobs/{job.pk}/"}, status=202)
long work leaves the requestIn the request✕user waits for minutes✕holds a worker the whole time✕dies at the first timeoutBackground task✓request returns 202 at once✓task runs on a worker✓page polls or gets notifiedRaising timeouts only moves the failure further awaydjango-deployment.com
Email the result link for exports that take more than a few seconds.

The client polls the status URL or receives an email when the report is ready. This keeps web workers available and makes timeouts irrelevant for that workload.

For downloads that must stream from Django, use StreamingHttpResponse: once the first bytes are sent, Nginx's proxy_read_timeout applies between successive reads rather than to the whole response.

7. Align timeouts deliberately

Set timeouts from the outside in so the innermost layer fails first and logs something useful:

LayerSettingExample
CDN or load balanceridle or origin timeout100s
Nginxproxy_read_timeout65s
Gunicorn--timeout60s
Django external callsrequests timeout10s
Databasestatement_timeout30s
inner timeouts shorter than outer onesGunicorn --timeout30 sNginx proxy_read_timeout60 sLoad balancer idle75 sCDN origin timeout100 s0 s25 s50 s75 s100 ssecondsThen the app logs the timeout, instead of an outer layer hiding itdjango-deployment.com
Load balancer idle timeouts should exceed Nginx keepalive to avoid resets.
location / {
    proxy_pass http://unix:/run/myapp/gunicorn.sock;
    proxy_connect_timeout 5s;
    proxy_read_timeout 65s;
    proxy_send_timeout 65s;
}

With Gunicorn's timeout slightly below Nginx's, a stuck worker is killed and logged by Gunicorn (the client sees a 502 with a clear WORKER TIMEOUT log line) instead of Nginx giving up silently while the worker keeps running.

A PostgreSQL statement_timeout for the application role prevents runaway queries from holding workers:

ALTER ROLE myapp SET statement_timeout = '30s';

Raise a specific location's timeout only for a known, bounded endpoint, not for the whole site.

8. Verify

curl -s -o /dev/null -w "status=%{http_code} total=%{time_total}\n" https://www.example.com/reports/annual/
sudo grep -c "upstream timed out" /var/log/nginx/error.log

Verification check:

  • the previously slow endpoint responds well under the timeouts, or returns 202 and completes in the background
  • no new upstream timed out lines appear during normal traffic
  • p95 $request_time for affected URLs has dropped
  • Gunicorn logs show no WORKER TIMEOUT entries

Explanation

A 504 is a symptom at the boundary between two components with different ideas of how long a request may take. Nginx stops waiting; the application may continue working and wasting a worker. Because workers are a fixed pool, a few slow requests reduce capacity for everyone, which turns an isolated slow page into site-wide timeouts. The durable fix is to keep request handling short and bounded, with every external wait protected by its own timeout.

Edge cases / notes

Timeouts only during deploys

If 504s cluster around releases, workers may be restarting slowly, or migrations may be locking tables. Check deploy timing against error timestamps and use graceful reloads.

Timeouts only under load

The endpoint is fine alone but slow when concurrent: database connection limits, lock contention, or CPU saturation. Load test in staging and watch database wait events.

Async views and ASGI

Running under ASGI does not make blocking code faster. Synchronous ORM calls in async views run in a thread pool and can still exhaust capacity.

Common mistakes

  • Setting proxy_read_timeout 3600s globally, which lets slow requests pile up for an hour.
  • Raising Gunicorn's timeout above Nginx's, so Nginx gives up first and the worker keeps running without a log entry.
  • Using requests without a timeout; its default is to wait indefinitely.
  • Treating a 504 on an export endpoint as a server problem instead of moving the export to a background job.

Next steps

FAQ

What is the difference between 502 and 504 in Django behind Nginx?

A 504 means Nginx connected to the app server but did not get a response within its read timeout. A 502 means the connection failed or the upstream returned an invalid response, for example after Gunicorn killed a worker.

Should I just increase proxy_read_timeout in Nginx?

Only for specific endpoints with a known, bounded duration. Raising it globally keeps slow requests occupying workers longer, which often causes more timeouts elsewhere.

How do I find which Django view causes 504 errors?

Add $request_time and $upstream_response_time to the Nginx log format, then sort requests by duration. The slowest URLs map to the views to investigate.

How should long-running Django requests be handled?

Start the work in a background task queue such as Celery, return 202 Accepted with a status URL, and notify the user or let the client poll until the result is ready.

Keep going