Home DevOps Nginx 504 Gateway Timeout — Fix Slow Upstream Fast
Intermediate 6 min · September 23, 2026

Nginx 504 Gateway Timeout — Fix Slow Upstream Fast

A 504 means Nginx waited but your app never answered.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 18 min
  • Basic Nginx reverse-proxy configuration
  • Familiarity with HTTP status codes
  • Access to server and app logs
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • A 504 means Nginx waited the full proxy_read_timeout and your upstream never answered — the app is slow, not unreachable
  • 502 means the upstream refused or died instantly; 504 means it accepted but never finished — check status codes first
  • Confirm with $upstream_response_time in access logs plus error.log 'upstream timed out' lines
  • Fix the slow code or query first, then size proxy_connect, proxy_send, proxy_read, and fastcgi_read_timeout to match reality
✦ Definition~90s read
What is Nginx 504 Gateway Timeout Fix?

A 504 Gateway Time-out means Nginx, acting as a gateway, forwarded the request to your upstream but didn't receive a complete response before its timer expired. The mechanism is three timers: proxy_connect_timeout (how long to wait for the TCP connection, default 60s), proxy_send_timeout (how long to wait while sending the request body), and proxy_read_timeout (how long to wait between bytes of the response, default 60s).

Imagine calling a restaurant: they pick up, say 'one moment please' — and never come back.

When proxy_read_timeout fires — the overwhelmingly common case — Nginx closes the upstream connection, returns 504 to the client, and logs 'upstream timed out' in error.log. Your app may still be computing the answer for a client that's already gone.

This is fundamentally different from a 502 Bad Gateway, and confusing the two wastes entire incidents. A 502 means the upstream connection failed fast — refused, reset, or closed with garbage — usually in milliseconds. A 504 means the connection succeeded but the response never completed within the window — always after the full timeout duration.

Check the timing first: failures at 0.003s are 502-class (upstream dead); failures at exactly 60.0s are 504-class (upstream slow). The access log's upstream_response_time makes this distinction trivial.

What a 504 is NOT: it's not a client-side network problem (the client reached Nginx fine), not a DNS failure (that fails before any timer starts), and not proof your app crashed (crashed apps produce fast 502s, not slow 504s). A 504 specifically testifies that the upstream was alive enough to accept the connection and slow enough to outlast the timer — so the investigation targets application latency (queries, downstream calls, locks) and timer sizing, in that order.

Plain-English First

Imagine calling a restaurant: they pick up, say 'one moment please' — and never come back. You wait until your phone gives up and hangs up on you. That's a 504. Your app didn't reject the call (a 502 would be a busy signal) — it just took so long that Nginx decided you'd waited enough and ended it. The fix is making the kitchen faster (your slow query or code) or telling Nginx to hold the line longer.

Your users stare at a loading spinner for exactly 60 seconds, then get a stark 504 Gateway Timeout page. Not a crash, not an error message from your app — a timeout notice from Nginx, delivered with metronomic precision at the same second every time. That precision is the clue: something is waiting on a fixed timer, and the timer always wins.

504s surface when real work gets slow: a report query that scans 40 million rows, a downstream payment API that hangs during their incident, a PHP worker stuck on a locked database row. Development never shows it because datasets are small and dependencies are local. Production combines all three at scale.

The trap is treating every 504 as 'increase the timeout.' Timeouts buy patience, not performance — raising proxy_read_timeout from 60s to 300s turns a 60-second failure into a 5-minute failure while worker connections pile up behind it. The timeout change is sometimes right, but only after you've proven what the upstream was doing with those seconds.

By the end of this article you'll separate 502s from 504s in one glance at the logs, measure exactly where upstream seconds go with timing variables, trace slow queries and hung downstream calls, and set each Nginx timeout (including fastcgi_read_timeout for PHP) to a value your system can actually honor.

502 vs 504: Read the Timing Before Anything Else

The single most valuable diagnostic in any gateway incident is the failure's timing signature. A 502 arrives fast — milliseconds — because the upstream refused the connection, reset it, or returned garbage immediately. A 504 arrives exactly at the timeout — 60.000 seconds with default settings — because Nginx waited the full window and gave up. Two incidents with identical user-facing 'the site is broken' reports have opposite causes, and the timestamp delta tells them apart before you open a single config file.

Make this distinction structural by logging it. The default Nginx access format hides upstream timing; adding $upstream_response_time, $upstream_connect_time, and $upstream_status turns every log line into a pre-diagnosed incident. Upstream_status 504 with response_time equal to proxy_read_timeout is the canonical slow-upstream fingerprint. Upstream connection errors with tiny response times are dead-upstream fingerprints. When both appear together, the dead upstream is usually a downstream casualty of the slow one (crashed under pileup), so chase the 504 first.

Error.log corroborates independently. 'upstream timed out (110: Connection timed out) while reading response header from upstream' is the 504 smoking gun; 'connect() failed (111: Connection refused)' and 'upstream prematurely closed connection' are 502-class. Grep both patterns in the incident window and count — the ratio tells you whether you're fighting one slow dependency or a dead tier.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Classify the incident in 60 seconds: timing + error-log pattern
# 1. Do 504s land exactly on the timeout? (slow upstream fingerprint)
awk '$9==504 {print $NF}' /var/log/nginx/access.log | sort -n | uniq -c | tail -5
# Expect: failures clustered at 60.000 (your proxy_read_timeout value)

# 2. Count 504-class vs 502-class evidence in error.log
sudo grep -c 'upstream timed out' /var/log/nginx/error.log
sudo grep -cE 'connect\(\) failed|connection refused|prematurely closed' /var/log/nginx/error.log

# 3. Permanent fix: log upstream timing on every request
# In nginx.conf http block:
# log_format timed '$remote_addr - $status $request_time '
#   'upstream=$upstream_response_time connect=$upstream_connect_time '
#   'ustatus=$upstream_status';
# access_log /var/log/nginx/access.log timed;
# Then: sudo nginx -t && sudo nginx -s reload
🔥Failures at Exactly 60.0s Are Slow, Not Dead
A dead upstream fails in milliseconds. Failures landing precisely on your proxy_read_timeout value prove the upstream accepted the connection and ran out the clock — investigate latency, not connectivity.
📊 Production Insight
Add upstream timing to your log format today, before the incident. Teams without it spend the first 30 minutes of every gateway outage arguing 502-versus-504 from user reports instead of reading one log column.
🎯 Key Takeaway
Millisecond failures mean dead upstream (502-class); timeout-value failures mean slow upstream (504-class). Log upstream timing permanently.

The Three Proxy Timers and What Each One Covers

Nginx splits gateway patience into three independent timers, and production configs routinely set the wrong one. proxy_connect_timeout governs the TCP handshake to the upstream (default 60s — generous; a healthy connect takes milliseconds, so a connect firing means network or DNS trouble). proxy_send_timeout governs writing the request body upstream (fires on huge uploads over slow links). proxy_read_timeout governs the gap between response bytes (the one that fires in nearly every 504 you'll ever see). Each defaults to 60s, and each must be set in the context that actually proxies — http, server, or location.

The subtlety is that proxy_read_timeout is an idle-between-bytes timer, not a total-request timer. A response that streams one byte every 59 seconds never trips a 60s read timeout even over an hour — while a response silent for 61 seconds trips it once. For SSE streams, long-polling, and slow file downloads, this means the correct fix is often a much larger read timeout on that specific location, not a global increase. Scope generous values to the streaming location and keep interactive endpoints tight.

Size timers from measured latency, not hope. If your p99 upstream time is 800ms, a 10s read timeout gives 12x headroom while still failing fast enough to protect worker pools. A checkout that legitimately needs 90s is a broken checkout — fix the query instead of blessing 120s. Every timeout second multiplied by concurrent slow requests is worker connections held hostage, which is how a slow endpoint becomes a full outage.

/etc/nginx/conf.d/upstream-timeouts.confNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Size from measured p99, scope per location — never one global value
upstream app_backend {
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

server {
    listen 80;
    server_name app.example.com;

    # Interactive API: tight — slow here is broken, fail fast
    location /api/ {
        proxy_pass http://app_backend;
        proxy_connect_timeout 5s;    # healthy connects take ms
        proxy_send_timeout 10s;
        proxy_read_timeout 30s;      # 12x+ headroom over 800ms p99
    }

    # Report export: legitimately slow, scoped generously
    location /api/reports/export {
        proxy_pass http://app_backend;
        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 300s;     # only where minutes are legitimate
    }
}
# Verify: sudo nginx -t && sudo systemctl reload nginx
📊 Production Insight
Global proxy_read_timeout 300s is the most common 504 'fix' and the most destructive — it converts every slow endpoint into a connection-hoarding outage amplifier. Scope long timers to the one location that earns them.
🎯 Key Takeaway
Connect, send, and read are separate timers. Keep interactive endpoints tight, scope generous values to proven-slow locations, and size from measured p99.

Trace the Slow Query: Database Locks and Missing Indexes

The database is the most frequent owner of Nginx's wasted 60 seconds. Two patterns dominate: the missing-index full scan, where a query that was instant on 10,000 dev rows grinds through 40 million production rows, and the lock wait, where your request isn't slow at all — it's queued behind another session's lock and burning the whole timeout standing still. Both look identical from Nginx (60.0s, then 504), and pg_stat_activity separates them in one query.

Run the active-session query during the incident, not after. Sessions with identical query_start ages and a wait_event_type of Lock are queued, not computing — find the blocking pid and the lock holder's query, which is usually a report, a migration, or an uncommitted idle transaction someone's laptop is holding open. Sessions with no lock wait but huge durations are genuinely slow — EXPLAIN ANALYZE them and look for Seq Scan on large tables, which names the missing index directly.

Fix at the source in priority order: kill the blocker to restore service, add the index to fix the plan, then move analytics off the write primary (read replica) so the pattern can't recur. Only when the query is genuinely as fast as physics allows and still exceeds the timer should the timer move — and then only for that endpoint's location block.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
# Catch the guilty query LIVE during the 504 window (Postgres)
psql -c "SELECT pid, now() - query_start AS duration, wait_event_type, left(query, 120) FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > interval '5 seconds' ORDER BY duration DESC;"
# wait_event_type = Lock -> queued behind a blocker, not slow itself
# wait_event_type null + huge duration -> genuinely slow, EXPLAIN it

# Find what holds the lock (run when wait events show Lock)
psql -c "SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, left(blocking.query, 100) FROM pg_stat_activity blocked JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));"

# Prove the plan is the problem, then fix with the index it names
psql -c "EXPLAIN ANALYZE SELECT * FROM orders WHERE status='pending' AND created_at > now() - interval '7 days';"
# Seq Scan on orders (cost=... rows=40000000) -> CREATE INDEX CONCURRENTLY
psql -c "CREATE INDEX CONCURRENTLY idx_orders_status_created ON orders (status, created_at);"
# Verify: re-run EXPLAIN ANALYZE, expect Index Scan and ms-scale time
📊 Production Insight
An idle-in-transaction session from a forgotten psql tab or a crashed deploy holds locks indefinitely and 504s unrelated endpoints. Alert on old idle-in-transaction sessions — they're invisible in app dashboards and lethal to latency.
🎯 Key Takeaway
Distinguish lock waits from slow plans with pg_stat_activity during the incident. Kill blockers first, index second, read-replica third — touch timers last.

Hung Downstream Calls: Timeouts, Breakers, and X-Ray Traces

When your app calls a payment provider, a fraud API, or another internal service that hangs, Nginx's 60 seconds get spent entirely inside your app waiting on someone else. App CPU stays idle, app logs show nothing until the downstream finally responds, and every diagnostic pointed at your own code comes back clean — because your code is innocent and parked on a socket read. Distributed tracing is the only honest witness: the trace shows your 60s span with 59s inside a single downstream child span.

Every outbound call needs its own timeout strictly shorter than Nginx's timer, or the downstream's hang becomes your 504 by construction. If Nginx allows 30s, downstream HTTP clients must time out at 5–10s with one retry at most — the remaining budget covers your own processing plus a clean error response. An outbound call with no timeout (still the default in several HTTP clients) can hang forever, holding a worker and eventually the whole pool.

Circuit breakers convert repeated downstream hangs from cascading 504s into fast, controlled failures. After N consecutive downstream timeouts, the breaker opens and fails immediately for a cooldown window — your endpoint returns 503-with-retry-after in milliseconds instead of 504ing for a minute per request. Users see a brief degradation; without the breaker they see a full outage plus a connection pileup that takes the database down with it.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Prove the downstream owns the seconds (no app changes needed)
# 1. Time your app directly, bypassing Nginx
time curl -s http://10.0.1.11:3000/api/checkout -X POST -d '{}' -o /dev/null
# real 0m59.8s -> the app itself waited ~60s

# 2. Time the downstream dependency alone
time curl -s https://payments-provider.example.com/health -o /dev/null
# real 0m55.1s -> 55 of your 60 seconds belong to the provider

# 3. Confirm in the trace (X-Ray): find the 60s segment's longest child
aws x-ray get-trace-summaries --start-time $(date -u -d '15 min ago' +%s) --end-time $(date -u +%s) --filter-expression 'service("checkout") AND duration > 50' --query 'TraceSummaries[0].Id'
# Open that trace: your span 60s, child span 'POST payments-provider' 55s

# 4. Fix shape (Node example): downstream timeout << Nginx timer
# const ctrl = new AbortController();
# const t = setTimeout(() => ctrl.abort(), 8000); // 8s vs Nginx 30s
# await fetch(providerUrl, { signal: ctrl.signal });
⚠ Outbound Calls Must Time Out Before Nginx Does
Any downstream call without its own shorter timeout converts the provider's hang into your 504 automatically. Audit every HTTP client for explicit timeouts — several popular clients still default to waiting forever.
📊 Production Insight
During a provider incident, your 504 rate is their latency graph shifted by your timeout. Watch provider status pages as a first-class diagnostic — the fastest 504 fix is sometimes their rollback, plus your breaker.
🎯 Key Takeaway
Trace to find which downstream owns the seconds, cap every outbound call well under Nginx's timer, and break circuits so one hung provider can't 504 your whole app.

PHP and fastcgi_read_timeout: The Timer Everyone Forgets

PHP-FPM deployments don't use proxy_read_timeout at all — they proxy over FastCGI, which has its own parallel timer family: fastcgi_connect_timeout, fastcgi_send_timeout, and fastcgi_read_timeout. Teams migrating timeout knowledge from Node/Python guides set proxy_read_timeout, reload, change nothing, and conclude 'timeouts don't fix it' — because the directive they set governs a module their traffic never touches. If your location uses fastcgi_pass, only fastcgi_* timers apply.

The rest of the 504 playbook is identical once the right timer is named: failures at exactly the fastcgi_read_timeout value are slow-PHP fingerprints, and PHP's own slow log (request_slowlog_timeout plus slowlog path in the FPM pool config) names the exact function and stack burning the seconds. A 60s fastcgi timer with a 60s PHP max_execution_time is a race you'll lose half the time — size FPM's execution limit comfortably above the FastCGI read timer so PHP logs its own fatal error instead of being silently cut off mid-request.

Also check FPM pool saturation, which masquerades as slowness. When pm.max_children is exhausted, requests queue inside FPM and burn the FastCGI timer waiting for a worker — the PHP code is fast but never gets to run. The FPM status page (pm.status_path) shows the listen queue directly; a nonzero queue with idle CPU means add children (within memory budget), not seconds. After setting the timer, reproduce the slow endpoint and confirm failures (if any) now land past the old 60s mark with slow-log entries naming the function — proof the FastCGI timer governs the path.

/etc/nginx/sites-available/php-app.confNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
server {
    listen 80;
    server_name shop.example.com;
    root /var/www/shop/public;

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        # The FastCGI timer family — proxy_read_timeout does NOTHING here
        fastcgi_connect_timeout 5s;
        fastcgi_send_timeout 10s;
        fastcgi_read_timeout 120s;   # scoped: only .php locations
    }
}
# PHP-FPM pool: log the guilty function (/etc/php/8.3/fpm/pool.d/www.conf)
# request_slowlog_timeout = 10s
# slowlog = /var/log/php-fpm/www-slow.log
# Check saturation: curl http://localhost/fpm-status | grep 'listen queue'
# Verify: sudo nginx -t && sudo systemctl reload nginx php8.3-fpm
📊 Production Insight
Keep max_execution_time above fastcgi_read_timeout (e.g. PHP 150s vs FastCGI 120s). If PHP's limit fires first you get a logged fatal error with a stack; if FastCGI fires first you get silence and a 504.
🎯 Key Takeaway
fastcgi_pass locations obey fastcgi_read_timeout, not proxy_read_timeout. Pair it with PHP's slow log and watch the FPM listen queue for saturation.

Prevention: Alerts on Upstream Latency, Not on 504s

Paging on the 504 rate means paging on user-visible failure — you're already late. The leading indicator is upstream response-time percentiles: alert when p99 crosses a fraction of the timeout (5s against a 30s timer) and you get hours of warning while users still see success. Track per-endpoint, because a global average hides the one report route climbing toward the cliff while checkout stays flat.

Make timeouts reviewable artifacts, not folklore. Every location's timers should carry a comment naming the measured p99 they were sized from and the date, so the next engineer knows whether 30s is data or superstition. CI can assert the basics: no location without explicit timers, no timer exceeding the worker-pool budget, fastcgi locations using fastcgi_* directives.

Finally, rehearse the slow-dependency scenario the way you rehearse failover. Chaos-test a 10x downstream latency injection quarterly and watch: do breakers open, do retries back off, do queues drain? The teams that survive provider incidents aren't the ones with the biggest timeouts — they're the ones whose systems fail fast, shed load, and recover the moment the dependency heals. Review the timer-comment dates in every post-incident: a sizing note older than two traffic doublings is stale data dressed as engineering, and re-measuring p99 takes less time than the next 504 page.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Leading-indicator alert: page on latency, not on user-facing 504s
# Prometheus + nginx-prometheus-exporter (or parse $upstream_response_time):
# ALERT UpstreamP99NearTimeout
#   IF histogram_quantile(0.99, nginx_upstream_seconds) > 5
#   FOR 10m  (timeout is 30s — pages with 6x headroom remaining)

# Weekly audit: every proxied location must set explicit timers
sudo grep -rL 'proxy_read_timeout\|fastcgi_read_timeout' /etc/nginx/sites-enabled/ /etc/nginx/conf.d/
# Any file listed proxies without an explicit timer — fix before it pages you

# Quarterly chaos drill: inject 10x downstream latency in staging, verify:
# 1. Breaker opens (fast 503s, not 60s 504s)
# 2. Frontend retries back off (no lock-queue pileup)
# 3. Recovery is instant when latency normalizes (no stuck connections)
tc qdisc add dev eth0 root netem delay 5000ms  # staging only, remove after
📊 Production Insight
The cheapest 504 insurance is a dashboard plotting p99 upstream time against the timeout line per endpoint. The day a line starts climbing toward its ceiling, you've found next month's outage with time to spare.
🎯 Key Takeaway
Alert on p99 latency approaching timers, document timer sizing in config comments, and chaos-test slow dependencies so breakers prove themselves before incidents.
● Production incidentPOST-MORTEMseverity: high

The 40M-Row Report Query That 504'd Checkout for 34 Minutes

Symptom
At 11:47 AM, checkout completion rates dropped 62% while the 504 rate on /api/checkout spiked from zero to 340 per minute. Every failure landed at exactly 60.0 seconds — the proxy_read_timeout value. The app servers showed low CPU and normal memory, which misled the first responders into suspecting the network. Meanwhile the database's active-session count climbed from 40 to 900 as retried checkouts piled new queries behind the stuck ones.
Assumption
The team assumed a network partition between Nginx and the app tier because app CPU was idle and 'the app looks healthy.' They restarted two app pods (no effect) and raised proxy_read_timeout to 300s (which made things worse — failures now took 5 minutes and held 5x the connections). Nobody looked at the database for 20 minutes because the checkout service's own dashboards don't include query latency.
Root cause
A new marketing report query ran every 5 minutes, full-scanning the 40-million-row orders table without an index on the filter column and taking 90+ seconds behind an ACCESS SHARE lock pattern that blocked checkout writes. Each checkout waited on the lock past the 60s proxy_read_timeout, Nginx returned 504, and the frontend's automatic retry fired another checkout into the same lock queue. Raising the timeout to 300s quintupled connection hold time and accelerated the pileup — the classic timeout-increase death spiral.
Fix
Three changes in sequence. First, the report query was killed and its cron disabled, which drained the lock queue in 4 minutes and restored checkout. Second, the missing composite index was added (query time dropped from 92s to 0.4s) and the report moved to a read replica so it can never lock checkout writes again. Third, proxy_read_timeout stayed at 60s for checkout (a checkout that needs more than 60s is broken by definition), the frontend retry was changed to retry-once with backoff instead of immediate retry, and a $upstream_response_time alert now pages when p99 exceeds 5s.
Key lesson
  • Failures landing at exactly the timeout value indict latency, not connectivity. Idle app CPU plus 60.0s failures means 'waiting on something' — check the database and downstream calls before restarting anything.
  • Raising timeouts during a latency incident amplifies it. Longer timers hold more connections behind the same slow resource, converting a slow endpoint into a full outage — fix the slowness first.
  • Retries without backoff turn one slow request into a pileup. A 504 that triggers an immediate identical retry doubles load on the exact resource that's already struggling.
Production debug guideFive log-and-trace checks that separate slow apps from wrong timers in minutes.5 entries
Symptom · 01
You need to confirm it's a 504-class (slow) failure, not 502-class (dead upstream)
Fix
Check status codes and upstream timing together: awk '$9==504' /var/log/nginx/access.log | tail -5 and grep 'upstream timed out' /var/log/nginx/error.log | tail -10. If failures cluster at exactly your proxy_read_timeout value (e.g. 60.000s upstream_response_time) with 'upstream timed out' lines, it's slowness. Millisecond failures with 'connect() failed' or 'connection refused' are 502-class — a dead upstream, not a slow one.
Symptom · 02
You don't log upstream timing yet and can't see where seconds go
Fix
Add timing variables to your log format and reload, then reproduce: add '$upstream_response_time $upstream_connect_time $upstream_header_time' to log_format in nginx.conf, run nginx -s reload, then tail -f /var/log/nginx/access.log while curl -s -o /dev/null -w 'total=%{time_total}s\n' https://app.example.com/slow-endpoint. If upstream_response_time ≈ total time, the app consumed the seconds; if connect_time dominates, it's network/DNS between Nginx and upstream.
Symptom · 03
The app tier is slow and you suspect the database
Fix
Catch the running slow query live instead of guessing: SELECT pid, now() - query_start AS duration, query FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > interval '5 seconds' ORDER BY duration DESC; (Postgres) or SELECT * FROM sys.processlist WHERE TIME > 5 ORDER BY TIME DESC (MySQL). Pair with EXPLAIN ANALYZE on the suspect query. A missing index or a full scan on a 40M-row table shows up here in seconds — fix with the index, not the timeout.
Symptom · 04
The app waits on a downstream API that might be hanging
Fix
Trace where the app's seconds go with a distributed trace or a direct timing probe: check your X-Ray/Jaeger trace for the span consuming the wall time, or bypass Nginx and time the app directly with time curl -s http://app-pod:3000/slow-endpoint. Then time the downstream alone: time curl -s https://payments-provider.example.com/health. If downstream takes 55s of a 60s failure, the fix is a circuit breaker and a shorter downstream timeout — not a longer Nginx timer.
Symptom · 05
PHP-FPM endpoints 504 while proxy-passed endpoints are fine
Fix
PHP doesn't use proxy_read_timeout — it uses fastcgi_read_timeout, which many configs never set: grep -rn 'fastcgi_read_timeout\|proxy_read_timeout' /etc/nginx/ to see which locations set what. Add fastcgi_read_timeout 120s; inside the matching location alongside fastcgi_pass, then nginx -t && systemctl reload nginx. Also check the FPM slow log (request_slowlog_timeout in php-fpm.conf) to find the PHP function burning the seconds.
Nginx 504 Causes — How to Confirm and Fix Each One
Root CauseHow to ConfirmFixPrevention
Slow query or lock wait in the databasepg_stat_activity shows long sessions; failures at exactly the timeoutKill blocker, add the missing index, move reports to a replicaAlert on p99 query time and idle-in-transaction sessions
Hung downstream API call with no client timeoutTrace shows 55s of 60s inside one downstream span; direct timing agreesCap outbound calls well under Nginx's timer; add a circuit breakerAudit every HTTP client for explicit timeouts in CI
proxy_read_timeout too tight for a legitimately slow endpointUpstream is healthy and fast except one route that needs minutesRaise proxy_read_timeout scoped to that location onlyComment timer sizing with measured p99 and review date
PHP traffic missing fastcgi_read_timeoutPHP locations 504 while proxy locations pass; grep shows no fastcgi timersSet fastcgi_read_timeout in fastcgi_pass locations; enable PHP slow logCI check: every fastcgi_pass location sets fastcgi_* timers
Retry storm piling load onto the slow resourceRequest rate multiplies during the incident; DB sessions climb with retriesRetry once with backoff and jitter, never immediatelyLoad-test retry behavior; alert on retry-rate spikes
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
awk '$9==504 {print $NF}' /var/log/nginx/access.log | sort -n | uniq -c | tail -...502 vs 504
etcnginxconf.dupstream-timeouts.confupstream app_backend {The Three Proxy Timers and What Each One Covers
psql -c "SELECT pid, now() - query_start AS duration, wait_event_type, left(quer...Trace the Slow Query
time curl -s http://10.0.1.11:3000/api/checkout -X POST -d '{}' -o /dev/nullHung Downstream Calls
etcnginxsites-availablephp-app.confserver {PHP and fastcgi_read_timeout
sudo grep -rL 'proxy_read_timeout\|fastcgi_read_timeout' /etc/nginx/sites-enable...Prevention

Key takeaways

1
Timeout-value failures mean slow upstream; millisecond failures mean dead upstream.
2
Log $upstream_response_time permanently
it pre-diagnoses every gateway incident.
3
Connect, send, and read are separate timers; PHP uses the fastcgi_* family.
4
Fix slow queries, lock waits, and hung downstreams before touching any timer.
5
Cap outbound calls under Nginx's timer and break circuits around flaky providers.
6
Alert on p99 latency approaching timers, never on the 504 rate alone.

Common mistakes to avoid

5 patterns
×

Raising proxy_read_timeout during an active latency incident

Symptom
Failures take longer, hold more connections, and the pileup accelerates — a 60s problem becomes a 5-minute outage.
Fix
Freeze timers during the incident, fix the slow query or downstream first, and only then size timers from the new measured p99.
×

Setting proxy_read_timeout for PHP-FPM traffic

Symptom
Timeout changes have zero effect on .php locations, leading to 'timeouts don't work' conclusions and random config thrash.
Fix
Use fastcgi_read_timeout in fastcgi_pass locations — proxy_* directives govern proxy_pass only and are silently ignored for FastCGI.
×

Treating 502 and 504 as the same 'gateway error'

Symptom
Restarting healthy app servers for a slow-query incident (or indexing a database for a crashed pod) wastes the golden hour.
Fix
Read timing first: millisecond failures are dead-upstream (502), timeout-value failures are slow-upstream (504), and each has its own playbook.
×

Retrying 504s immediately with no backoff

Symptom
Every timeout spawns an identical twin hammering the same lock or downstream, multiplying load precisely when the system is weakest.
Fix
Retry at most once with exponential backoff plus jitter, and make read-only endpoints idempotent so retries are safe.
×

Running outbound HTTP calls with no timeout shorter than Nginx's

Symptom
A provider's 3-hour incident becomes your 3-hour 504 outage, with workers parked on socket reads the whole time.
Fix
Cap every downstream call well under the Nginx timer and wrap repeated callers in a circuit breaker that fails fast.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Users get 504s at exactly 60 seconds. What does the precision tell you?
Q02SENIOR
How do you distinguish a 502 from a 504 using only Nginx logs?
Q03SENIOR
Why is raising the timeout the wrong first move during a 504 spike?
Q04SENIOR
PHP endpoints 504 but proxy_pass endpoints don't, with identical timeout...
Q05SENIOR
Design a resilient checkout that depends on a sometimes-slow payment API...
Q01 of 05JUNIOR

Users get 504s at exactly 60 seconds. What does the precision tell you?

ANSWER
A fixed timer fired — almost certainly proxy_read_timeout (or fastcgi_read_timeout) at its 60s default. The upstream accepted the connection but never completed the response in the window. That rules out dead-upstream and network-partition theories and points at application latency: slow query, lock wait, or hung downstream call.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is a 504 my app's fault or Nginx's fault?
02
What's the difference between 502 Bad Gateway and 504 Gateway Timeout?
03
What should proxy_read_timeout be set to?
04
Why don't timeout changes affect my PHP app?
05
Should the frontend retry requests that 504?
06
How do I stop one slow downstream API from taking down my whole app?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Networking. Mark it forged?

6 min read · try the examples if you haven't

Previous
x509 Signed by Unknown Authority Fix
4 / 4 · Networking
Next
S3 SignatureDoesNotMatch Fix