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.
Quick answer
- Confirm the 504 comes from Nginx: look for
upstream timed out (110: Connection timed out) while reading response header from upstreamin the Nginx error log. - Identify affected URLs and timing from the access log with
$request_timeand$upstream_response_time. - Check whether all workers were busy (queueing) or one endpoint is slow.
- For slow endpoints, profile queries and external calls; fix N+1 queries, add indexes, set timeouts on HTTP clients.
- Move legitimately long work (exports, imports, report generation) to background tasks.
- Align timeouts from the outside in: load balancer ≥ Nginx
proxy_read_timeout≥ Gunicorn--timeout. - 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
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.
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)
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:
| Layer | Setting | Example |
|---|---|---|
| CDN or load balancer | idle or origin timeout | 100s |
| Nginx | proxy_read_timeout | 65s |
| Gunicorn | --timeout | 60s |
| Django external calls | requests timeout | 10s |
| Database | statement_timeout | 30s |
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
202and completes in the background - no new
upstream timed outlines appear during normal traffic - p95
$request_timefor affected URLs has dropped - Gunicorn logs show no
WORKER TIMEOUTentries
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 3600sglobally, 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
requestswithout 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
- If Gunicorn kills workers, see Gunicorn Worker Timeout in Django: Root Causes and Fixes.
- Find hot code paths with Profile Slow Django Endpoints in Production with py-spy.
- Remove query storms with Find and Fix N+1 Queries in Django.
- For connection failures instead of timeouts, see Django 502 Bad Gateway: Causes and Fixes.
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.