504 Gateway Timeout (tested)

504 Gateway Timeout means a proxy, load balancer or CDN waited for the server behind it and gave up. The server you talk to is fine. The one behind it was too slow. We built one on this box and timed it.

1. The setup

Three small Node.js processes. An upstream that waits 5 seconds before it answers. A reverse proxy that forwards to it and gives up after 2 seconds. And a plain server with short client timeouts, for the 408 comparison.

// proxy.js (port 8986) - the important part
const u = http.request({host: '127.0.0.1', port: 8987, path: req.url, method: req.method, headers: req.headers}, up => {
  res.writeHead(up.statusCode, up.headers); up.pipe(res);
});
u.setTimeout(2000, () => {           // upstream timeout
  u.destroy();
  res.writeHead(504, {'content-type': 'text/plain', 'via': '1.1 tiny-proxy'});
  res.end('504 Gateway Timeout: upstream did not answer in 2s\n');
});

2. What the proxy returns

$ curl -si -w 'time_total=%{time_total}\n' 'http://127.0.0.1:8986/report?ms=5000'
HTTP/1.1 504 Gateway Timeout
content-type: text/plain
via: 1.1 tiny-proxy
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

504 Gateway Timeout: upstream did not answer in 2s
time_total=2.012901

The client got a 504 after 2.01 s, not after 5 s. A request that the upstream answers in 0.5 s went through the same proxy fine: 200 OK, time_total=0.506641.

The body and headers of a 504 come from the proxy, not from your app. The Via or Server header often tells you which hop produced it. See the Via header guide.

3. The upstream keeps working after the proxy gives up

This is the part people miss. The upstream log from the same run:

[upstream] start /report?ms=5000 02:39:17.100Z
[proxy] upstream timeout after 2008ms -> 504
[upstream] finished /report?ms=5000 after 5003ms (socket destroyed=true)

The proxy closed the connection at 2 s. The upstream did not notice. It kept running for 3 more seconds and then wrote its answer into a dead socket. That is wasted work. If the request was "charge the card" or "send the email", it may still have happened, even though the client saw an error. If the client retries, it can happen twice.

Node does not stop your handler when the client goes away. To stop early, listen for req.on('close') or check res.destroyed / req.socket.destroyed and abort the work yourself.

4. 504 vs 408 vs a client-side timeout

Three different things time out in three different places. We triggered each one.

What happenedWho waitedWhat the client sees (measured)
Proxy waited for upstreamThe proxy504 Gateway Timeout after 2.01 s
Server waited for the client to finish sending the requestThe server408 Request Timeout, Connection: close
Client gave up first (curl --max-time 1)The clientNo status at all. http_code=000, curl exit 28, after 1.00 s
Client gave up first (Node fetch + AbortSignal.timeout(1000))The clientNo status. TimeoutError: The operation was aborted due to timeout after 1004 ms
$ curl -s -o /dev/null -w 'code=%{http_code} time=%{time_total}\n' --max-time 1 'http://127.0.0.1:8987/x?ms=5000'
code=000 time=1.001284
$ echo $?
28
// fetch with a timeout
try { await fetch(url, {signal: AbortSignal.timeout(1000)}) }
catch (e) { console.log(e.name, e.message) }
// TimeoutError The operation was aborted due to timeout

In both client-side cases the upstream again ran its full 5 s (finished after 5001ms and 5003ms). Giving up on the client side does not stop the server either.

For the 408 we opened a socket, sent GET / HTTP/1.1 and one header, then never sent the blank line. More on that in the 408 guide.

5. Node.js http.Server defaults (measured)

$ node -e "const s=require('http').createServer(); console.log(s.requestTimeout, s.headersTimeout, s.keepAliveTimeout, s.timeout, s.connectionsCheckingInterval)"
300000 60000 5000 0 30000

These are about the client being slow, not your handler. Node has no default limit on how long your handler may take to answer.

One surprise: we set both timeouts to 2000 ms, but the 408 arrived after 10.95 s in one run and 20.02 s in another. Node only checks these timeouts every connectionsCheckingInterval (30000 ms by default). So the real cutoff is the timeout plus up to 30 s.

6. Common real-world causes

From the docs, not measured. The usual default limits at each hop:

HopDefault limitWhat you get
nginx proxy_read_timeout60 s between two reads from upstream504
Cloudflare proxy100 s for the origin to start answering524 (Cloudflare's own code, not 504)
AWS Application Load Balancer60 s idle timeout504
AWS API Gateway (REST, integration timeout)29 s504

Typical reasons the upstream is slow: a slow database query or lock, a call to a third-party API that hangs, a big report or export done inside the request, a cold start, or the app being out of workers so the request sits in a queue. Sometimes the upstream is just down and the proxy waits for a connect that never completes.

The shortest timeout in the chain wins. If nginx allows 60 s but API Gateway allows 29 s, a 40 s request fails at 29 s.

7. Fixes

  1. Make long work async. Answer 202 Accepted right away with a job URL. Do the work in the background. The client polls the job URL until it is done. Tell it how often with Retry-After (see Retry-After). This is the real fix for anything that can take more than a few seconds.
  2. Raise the timeout at every hop, not just one. App server, nginx, load balancer, CDN, and the client. Each one must be longer than the one behind it. Some limits (like API Gateway's) are hard to raise.
  3. Stream early. Send headers and the first bytes quickly, then the rest as it is ready. Many proxies time out on silence between reads, not on total time. Note: once headers are sent, a late failure can no longer become a clean 504.
  4. Make the slow part faster. Add an index, cache the result, set a short timeout on your own outbound calls so they fail fast instead of hanging.
  5. Stop wasted work. Watch for the client disconnect and cancel. Make retried writes idempotent, because as we measured, the work can finish after the 504.

Frequently asked questions

Is a 504 my server's fault or the proxy's?

The proxy produced it, but the cause is almost always the server behind it being too slow or unreachable. In our test the proxy answered 504 after 2.01 s because the upstream needed 5 s.

Did my request still run if I got a 504?

Often yes. In our test the upstream finished its work 3 seconds after the proxy had already returned 504. Check before you retry anything that writes data.

What is the difference between 504 and 408?

504: a proxy waited for the server behind it. 408: the server waited for the client to finish sending the request. A client-side timeout gives no status at all, for example curl exit 28 or a TimeoutError from fetch.

Why does Cloudflare show 524 instead of 504?

From Cloudflare's docs, not measured: 524 is Cloudflare's own code for an origin that did not answer within its 100 second limit. It means the same kind of thing as a 504.

What is the best fix for long requests?

Return 202 Accepted with a job URL and let the client poll. Raising timeouts at every hop or streaming early can help, but async work removes the limit entirely.

Written with AI assistance. Measured on 2026-10-02 with curl 8.14.1, Node.js 24.21.0 and Python 3 stdlib: a tiny Node reverse proxy on 127.0.0.1:8986 with a 2 s upstream timeout in front of a Node upstream on 127.0.0.1:8987 that sleeps 5 s, plus a Node server on 127.0.0.1:8988 with headersTimeout and requestTimeout set to 2 s. Platform timeouts (nginx, Cloudflare, AWS) are from their docs, not measured. PlainHeaders is our extension; curl, Python and Node.js are free.