504 Gateway Timeout
Server error response defined by RFC 9110 §15.6.5.
Written and maintained by Ben Ennis
Last reviewed July 27, 2026 · How we verify this
Common causes at a glance
- Proxy read timeout shorter than the upstream's real processing time
- A slow database query or third-party API call inside the request path
- Upstream worker pool exhausted, so requests queue before they start
- Timeouts configured in the wrong order across CDN, load balancer, and origin
Reproduce it
curl -sS -o /dev/null -w 'code=%{http_code} t=%{time_total}\n' https://www.example.com/reports/bigWhat 504 Gateway Timeout means
Every 504 is a disagreement between two timeouts. A request passes through a chain — browser, CDN, load balancer, reverse proxy, application server, database — and each hop has its own patience. Whichever is shortest decides the outcome. A 504 means an intermediate hop’s limit fired while the hop below it was still working, so the work continues, invisibly, after the client has been told it failed.
The near-universal reflex is to raise the timeout on whichever box emitted the 504. That is usually wrong. Raising one number moves the failure to the next hop up, which now times out instead, while you hold connections open for a request nobody is waiting for. Write down every timeout in the path and make them decrease as you move toward the client, so the hop closest to the slow work gives up first and can say something specific about why. Then make the slow endpoint asynchronous.
What the spec says
RFC 9110 §15.6.5 defines 504 as a gateway or proxy not receiving a timely response from an upstream server it needed to access. “Timely” is deliberately undefined: the specification sets no duration and leaves the threshold to the intermediary. The contrast with §15.6.3 is the useful part — 502 is an invalid response, 504 is no response in time. Nothing makes 504 retryable, and blindly retrying a non-idempotent request that timed out is how duplicate orders get created.
What actually causes it
The endpoints that generate 504s are predictable: report generation, bulk exports, search across a large index, anything calling a payment or shipping provider synchronously. A 504 whose total time lands on a suspiciously round number — 30, 60, 120 seconds — is showing you the timeout value itself, and that number identifies the hop that gave up. In nginx the relevant knobs are proxy_connect_timeout and proxy_read_timeout, whose 60-second read default is why so many 504s arrive at exactly one minute. Worker exhaustion produces the same symptom with no slow query at all.
How to debug it
Measure first. Elapsed time before the 504 names the hop that gave up:
curl -sS -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' \
https://www.example.com/reports/big
Match that total against each configured timeout in the chain. Then run the same request directly against the origin with a generous client timeout: if the origin finishes in 90 seconds and the proxy waits 60, you have your answer without touching any code. Check the upstream’s logs at that timestamp — a 200 logged by the application for a request the client saw as 504 confirms the work was done and only the reporting was lost. Build a repeatable probe with the curl builder.
Cloudflare’s edge timeout is a ceiling your origin config cannot raise
The timeout-chain principle above assumes every hop’s limit is something you control.
Cloudflare’s proxy breaks that assumption for most plans. Its documented
connection limits between Cloudflare and the origin
list a Proxy Read Timeout of 120 seconds, returned as a 524 rather than a plain 504, and
mark it configurable only for Enterprise zones — on Free, Pro, and Business plans the
number is fixed regardless of what proxy_read_timeout you set on your own
nginx or Apache in front of the origin. Raising your origin’s own timeout past 120
seconds accomplishes nothing on those plans: Cloudflare closes the connection and serves
524 at exactly its own limit, not yours, so the “raise the timeout” reflex this page
warns against is not merely bad practice here, it is architecturally impossible.
The distinct status code matters operationally. 524 confirms Cloudflare completed the
TCP connection to your origin and was waiting on the HTTP response itself, narrowing the
search to your application’s processing time rather than network reachability, which a
plain 502 or connection-refused error would instead point at.
Since the ceiling cannot move on most plans, the only real fixes are to make the
endpoint respond inside the window — typically by returning a fast 202
Accepted with a job ID and polling for the result — or to route that specific
subdomain around Cloudflare’s proxy entirely (the DNS-only “grey cloud” setting), which
trades away every other Cloudflare feature for that hostname in exchange for an
unbounded origin timeout.
Working examples
curl
# The total time before the 504 usually equals the offending hop's timeout.
curl -sS -o /dev/null \
-w 'code=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://www.example.com/reports/big
# How long does the origin really take? Give the client plenty of rope.
curl -sS -o /dev/null --max-time 300 \
-w 'origin code=%{http_code} total=%{time_total}\n' \
http://10.0.1.24:8080/reports/big
Python (requests)
import requests
# (connect timeout, read timeout). Set the read timeout well above the
# gateway's so you can see whether the origin eventually answers at all.
try:
resp = requests.get("http://10.0.1.24:8080/reports/big", timeout=(3, 300))
print("origin finished in", resp.elapsed.total_seconds(), "s:", resp.status_code)
except requests.exceptions.ReadTimeout:
print("origin itself never answered; the proxy is not the problem")
edge = requests.get("https://www.example.com/reports/big", timeout=(3, 300))
print("edge:", edge.status_code, edge.elapsed.total_seconds())
Node (fetch)
// Time the edge, then the origin, and compare against configured timeouts.
async function timed(url, ms) {
const t0 = performance.now();
const secs = () => ((performance.now() - t0) / 1000).toFixed(1);
try {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
return `${res.status} in ${secs()}s`;
} catch {
return `aborted after ${secs()}s`;
}
}
console.log("edge :", await timed("https://www.example.com/reports/big", 120000));
console.log("origin:", await timed("http://10.0.1.24:8080/reports/big", 300000));Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Related reading
- SLOs, SLIs, and error budgets: a primer
This is the kind of error you should be measuring against an error budget rather than paging on individually. How to pick an indicator and alert on burn rate.
- Karpenter vs cluster-autoscaler in 2026
On Kubernetes this status often tracks node churn rather than application failure. How the two autoscalers differ now that they share an API.
- Every HTTP status code, and when it shows up
The wider map: which codes you actually meet in production, and the pairs everyone confuses.
Other 5xx codes
- 500 Internal Server Error
- 501 Not Implemented
- 502 Bad Gateway
- 503 Service Unavailable
- 505 HTTP Version Not Supported
- 506 Variant Also Negotiates
- 507 Insufficient Storage
- 508 Loop Detected
- 510 Not Extended
- 511 Network Authentication Required
- 520 Web Server Returns an Unknown Error
- 521 Web Server Is Down
- 522 Connection Timed Out
- 523 Origin Is Unreachable
- 524 A Timeout Occurred
- 525 SSL Handshake Failed
- 526 Invalid SSL Certificate
- 530 Unable to Resolve Origin Hostname