What a 504 means

A proxy (nginx, a load balancer, a CDN) forwarded your request to the server behind it, started waiting for the answer, and gave up when its timer ran out. RFC 9110 puts it as not receiving “a timely response from an upstream server”. That’s the difference from a 502: with a 504 nothing came back broken, nothing came back at all, at least not in time.

Two things follow from that.

First, a 504 is a statement about a timer. Some layer has a limit, often 30, 60 or 100 seconds, and the request outlived it. The delay before the error is your best clue, because it tells you which layer’s timer fired.

Second, the backend may not have failed. It may still be working, and it may finish. The proxy stopped waiting; that doesn’t mean the database query, the report export or the payment call was cancelled.

If you’re visiting the site

Nothing on your device caused this, and changing browsers won’t help. Try again in a few minutes; 504s often come in waves when a site is overloaded.

If the 504 appeared after you submitted something (an order, a payment, a form that sends email), don’t hit submit again straight away. The server may have completed the action after the proxy gave up on you. Check your email or account for a confirmation first.

If only your work network shows 504s, and the error page carries your company’s proxy branding, your own corporate proxy timed out. Try from a phone on mobile data.

Measure the delay

curl can show both the status and exactly how long the request took:

curl -s -o /dev/null -w 'code=%{http_code} connect=%{time_connect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
  https://example.com/reports/annual
code=504 connect=0.014s ttfb=60.042s total=60.043s

A fast connect and a time-to-first-byte pinned to a round number is the signature. Then match the number against the defaults of the layers you have:

LayerDefault limitWhat you see when it fires
nginx proxy_read_timeout (also fastcgi_read_timeout, uwsgi_read_timeout)60 s504, page titled “504 Gateway Time-out” with an nginx footer
nginx proxy_connect_timeout60 s504, logged “while connecting to upstream”
AWS Application Load Balancer idle timeout60 s504, Server: awselb/2.0
Amazon CloudFront origin response timeout30 s504, page “Generated by cloudfront (CloudFront)”
Cloudflare proxy100 sCloudflare error 524, not 504
Gunicorn worker timeout30 s502, not 504: gunicorn kills the worker and nginx sees the connection drop

These are defaults. If your number doesn’t match any of them, someone configured a different value; nginx -T | grep -n timeout shows what nginx actually runs with.

The last two rows matter. Behind Cloudflare, an origin timeout appears as 524, so a plain 504 behind Cloudflare came from a layer behind it, usually your own nginx or load balancer. And when the app server’s own timer is shorter than the proxy’s, you get a 502 for what is really a slow request. Whichever timer is shortest decides which code the user sees.

The HTTP check is handy to see the status code and Server header that outside visitors get, but it stops waiting after 15 seconds, so a request that takes a full minute to time out shows up there as no answer. Use curl from your own machine for the long ones.

Which layer generated it

Look at the headers and the page body:

curl -sv -o /dev/null https://example.com/reports/annual 2>&1 | grep -iE '^< (HTTP|server|via|x-cache|cf-ray)'
  • Server: awselb/2.0 with a bare page: the ALB timed out waiting for its target.
  • X-Cache: Error from cloudfront: CloudFront timed out waiting for your origin.
  • An nginx footer or Server: nginx: nginx timed out waiting for its upstream.
  • HAProxy’s default 504 page says “The server didn’t respond in time.”
  • Behind Cloudflare, every response has Server: cloudflare, so read the body instead. A plain nginx 504 page passed through means your origin’s nginx timed out.

The cause is always behind the layer that complained. If the ALB sent the 504, look at what the ALB’s targets were doing, not at the ALB.

Read the nginx error log

When nginx generates the 504 it logs the reason. Something like:

2026/10/07 14:03:51 [error] 2114#2114: *9310 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 198.51.100.7, server: example.com, request: "GET /reports/annual HTTP/1.1", upstream: "http://127.0.0.1:8000/reports/annual", host: "example.com"

The phrase after “while” tells you which phase timed out:

  • “while reading response header from upstream”: nginx connected and sent the request, and the backend didn’t start answering within proxy_read_timeout (or fastcgi_read_timeout for PHP). The backend is slow. This is the common case.
  • “while connecting to upstream”: nginx couldn’t even complete the TCP connection within proxy_connect_timeout. Something drops packets (a firewall or security group that silently discards instead of rejecting), the address is wrong, or the backend’s accept queue is full. A refused connection would be an instant 502; a timeout here means silence.
  • “while reading upstream”: the backend started answering, then stalled mid-body for longer than the read timeout.

One detail about proxy_read_timeout that changes how you fix things: it isn’t a limit on the whole response. It’s the maximum gap between two successive reads. A backend that sends a byte every 20 seconds never hits a 60-second read timeout, which is why server-sent events and long-polling endpoints survive with a heartbeat and die without one.

The usual causes

1. One route is genuinely slow

The 504 follows one endpoint: a report, an export, a search with odd filters, an admin page that loads everything. Static files and the homepage are fine.

Find out where the time goes. Call the backend directly from the proxy host to take the proxy out of the picture:

time curl -s -o /dev/null http://127.0.0.1:8000/reports/annual

Then look at the usual suspects: a query without an index (the database’s slow query log will show it), an N+1 loop that runs thousands of small queries, or a synchronous call to a third-party API that is itself slow or down. In PHP, note that max_execution_time on Linux doesn’t count time spent waiting on the database or the network, so a script blocked on a slow query can outlast it and nginx’s 60 seconds fire first.

The real fix is making the route fast or taking the work out of the request. For anything that legitimately takes minutes, accept the job, return 202 Accepted with a job ID, do the work in a queue, and let the page poll. That pattern doesn’t care about any proxy’s timeout.

2. The backend is saturated, so everything is slow

The 504s come in bursts, affect many routes, and line up with traffic peaks. Each request is quick on its own, but they’re waiting in line for a free worker.

The tell is in the app server’s log. PHP-FPM, for example, warns something like:

WARNING: [pool www] server reached pm.max_children setting (10), consider raising it

Requests beyond that limit wait in the socket’s backlog until nginx gives up. Similar limits exist everywhere: gunicorn’s worker count, a database connection pool that is exhausted, a thread pool in a Java app server. Raising the pool helps only if the machine has the memory and the database has the connections to support it. Often the real cause is a few slow requests (cause 1) occupying all the workers, and everything else queues behind them.

3. A stale upstream address

nginx resolves hostnames in proxy_pass once, when it starts or reloads, and keeps that IP. If the upstream is something whose IP changes, like a cloud load balancer, a container that was rescheduled or a managed service, nginx keeps connecting to the old address. If that address now silently drops packets, you get “while connecting to upstream” timeouts that a reload magically fixes.

Check what the name resolves to now with dig +short internal-api.example.com or a DNS lookup for public names, and compare it with the upstream: address in the log line. The fix is to make nginx re-resolve: define a resolver and use a variable in proxy_pass:

resolver 10.0.0.2 valid=30s;
set $api_upstream http://internal-api.example.com;

location /api/ {
    proxy_pass $api_upstream;
}

When proxy_pass contains a variable, nginx resolves it at request time through resolver. (Note that with a variable, nginx passes the request URI differently than with a static URI part; test your paths.)

4. A firewall that drops instead of rejecting

Connect timeouts with no DNS change usually mean a firewall rule or security group that silently discards the proxy’s packets. From the proxy host:

nc -vz -w 5 10.0.3.17 8000

A refusal comes back instantly; a drop hangs until the timeout. If the backend is meant to be publicly reachable, the port check tests the same thing from outside. For internal addresses only a test from the proxy host means anything.

5. The timeouts in the chain don’t line up

A request crosses several layers, each with its own clock: CDN, load balancer, nginx, app server, database. The shortest one fires first. If you raise nginx’s proxy_read_timeout to 300 seconds but the ALB idle timeout is still 60, the user still gets a 504 at 60 seconds, just from the ALB now.

If a route really needs longer, raise the limit only there, and raise every layer from the outside in:

location /reports/ {
    proxy_pass http://app;
    proxy_read_timeout 300s;
}

Then the load balancer’s idle timeout, then the app server’s own worker timeout. Behind Cloudflare, the default 100 seconds is the ceiling on most plans; anything slower must become a background job anyway.

Don’t raise the global timeout to make a 504 go away. A 300-second timeout everywhere means a stuck backend now holds every connection for five minutes instead of one, and a small slowdown turns into a full outage much faster.

After the fix

  • Re-run the curl -w command. A healthy route returns well below any timeout, not just under it. A response that takes 58 seconds against a 60-second limit is a 504 waiting for a busy day.
  • Watch sudo tail -f /var/log/nginx/error.log while you load the route. “upstream timed out” lines should stop.
  • If you moved work to a background job, test the polling path too, including what happens when the job fails.
  • 502 Bad Gateway: the proxy got a broken answer or the connection failed, usually immediately.
  • 503 Service Unavailable: the service refused deliberately: maintenance, rate limiting, or no healthy backends.
  • Cloudflare error 522: Cloudflare couldn’t complete a TCP connection to your origin at all.
  • ERR_CONNECTION_TIMED_OUT: your browser never reached the site’s front server, so there’s no HTTP status at all.

Check your own domain now

Free, no sign-up. Runs the exact check this guide describes and shows what to fix.