502 Bad Gateway (tested)

A 502 Bad Gateway means a proxy or gateway could not get a valid response from the server behind it. The proxy itself is up. The problem is the hop to the upstream: nothing listening, the connection was reset, or the reply was not valid HTTP.

1. The test setup

We wrote a 20-line reverse proxy with Node's http module. It forwards each request to the upstream. If http.request emits an error before any headers were sent, it answers 502 with the error code in the body. This is roughly what nginx, HAProxy and cloud load balancers do, but they write their own error pages.

const p = http.request({host: '127.0.0.1', port: 8984, path: req.url,
                        method: req.method, headers: req.headers}, up => {
  res.writeHead(up.statusCode, up.headers);
  up.pipe(res);
});
p.on('error', e => {
  if (!res.headersSent) {
    res.writeHead(502, {'Content-Type': 'text/plain'});
    res.end('502 Bad Gateway: ' + e.code + '\n');
  } else {
    res.destroy();   // too late for a 502: headers already went out
  }
});

2. Upstream not listening (ECONNREFUSED)

With nothing on port 8984, the proxy's connect failed at once with ECONNREFUSED (message: connect ECONNREFUSED 127.0.0.1:8984). The client got a clean 502:

$ curl -si http://127.0.0.1:8983/ok
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

502 Bad Gateway: ECONNREFUSED

Once we started the upstream, the same request returned 200 OK with hello. A refused connection is the most common 502: the app is down, restarting, or on another port or address.

3. Upstream resets the socket before replying

The upstream accepted the request and then called socket.destroy() without writing anything. Node reported ECONNRESET with the message socket hang up. The proxy had not sent headers yet, so the client again saw a normal 502:

$ curl -si http://127.0.0.1:8983/early
HTTP/1.1 502 Bad Gateway
...
502 Bad Gateway: ECONNRESET

4. Upstream crashes mid-response: no 502 at all

Here the upstream sent 200 with Content-Length: 100, wrote 7 bytes (partial), then destroyed the socket. The proxy had already forwarded the 200 headers. It cannot change the status any more. Node fired aborted and then an ECONNRESET (aborted) error on the upstream response, and our proxy cut the client connection.

$ curl -si http://127.0.0.1:8983/mid
HTTP/1.1 200 OK
content-length: 100
...

partial
$ echo $?
18

curl exit code 18 means "partial file": fewer bytes than Content-Length promised. With Node's fetch(), res.status was 200 and res.ok was true, but await res.text() threw TypeError: terminated with cause.code = UND_ERR_SOCKET. So a crash after the headers looks like a broken download, not a 502.

5. Upstream sends invalid HTTP

A raw TCP server on 8985 answered every request with HELLO THIS IS NOT HTTP. Node's HTTP parser rejected it with code HPE_INVALID_CONSTANT and message Parse Error: Expected HTTP/, RTSP/ or ICE/. The client got:

$ curl -si http://127.0.0.1:8983/g
HTTP/1.1 502 Bad Gateway
...
502 Bad Gateway: HPE_INVALID_CONSTANT

Real-world versions of this: pointing an HTTP proxy at a TLS port, a database port, or a FastCGI socket.

6. How fetch() sees a 502

A 502 is a normal HTTP response. fetch() does not throw. In Node 24 we got:

const r = await fetch('http://127.0.0.1:8983/g');
console.log(r.status, r.statusText, r.ok);
// 502 Bad Gateway false
await r.text();   // "502 Bad Gateway: HPE_INVALID_CONSTANT\n"

Check res.ok or res.status yourself. A try/catch around fetch() only catches network failures, like the mid-response crash above. A 502 is often temporary (an app restart), so a short retry with backoff is reasonable for idempotent requests. See Retry-After for retry timing.

7. Summary of what we measured

Upstream failureNode error codeClient saw
Nothing listeningECONNREFUSED502
Socket destroyed before replyECONNRESET (socket hang up)502
Socket destroyed after headersECONNRESET (aborted)200, truncated body, curl exit 18
Non-HTTP replyHPE_INVALID_CONSTANT502

8. Common real-world causes (from the docs, not measured)

nginx: connect() failed (111: Connection refused) while connecting to upstream. This is the same as our ECONNREFUSED case. Errno 111 is ECONNREFUSED on Linux. The app is down, or proxy_pass points at the wrong host or port. Check with curl -i http://127.0.0.1:PORT/ from the proxy host itself.

nginx: upstream prematurely closed connection while reading response header from upstream. This matches our "reset before reply" case. The app crashed, was killed (for example by the OOM killer), or a worker hit its own timeout and closed the socket. Look at the app's logs at the same timestamp.

php-fpm socket. With fastcgi_pass unix:/run/php/php-fpm.sock; nginx logs errors like connect() to unix:/run/php/... failed (2: No such file or directory) when php-fpm is stopped or the socket path differs between versions, and (13: Permission denied) when the nginx user cannot open the socket. Fix the path, start php-fpm, or set listen.owner/listen.group/listen.mode in the pool config.

App listens on 127.0.0.1 inside a container. Inside a Docker container, 127.0.0.1 is the container's own loopback. A proxy in another container, or on the host via a published port, cannot reach it, and you get connection refused or reset. Bind the app to 0.0.0.0 and point the proxy at the service name (for example http://app:3000) on a shared network.

Other causes. HTTP proxy pointed at an HTTPS port (invalid response, like our garbage test). Response headers larger than the proxy's buffer (nginx: upstream sent too big header). Idle keep-alive connections closed by the upstream just as the proxy reuses them; see Keep-Alive. A slow upstream usually gives 504 Gateway Timeout instead, not 502; for client-side timeouts see 408 Request Timeout.

9. Fix checklist

  1. Read the proxy's error log first. The exact message tells you which case you have.
  2. From the proxy host, curl the upstream address directly. Refused means the app is down or on another address.
  3. Check the app logs and dmesg for crashes and OOM kills at the same time.
  4. Confirm the protocol: HTTP vs HTTPS, HTTP vs FastCGI, correct socket path.
  5. In containers, bind to 0.0.0.0 and use the service name, not localhost.
  6. Make sure your proxy sets the right forwarding headers; see X-Forwarded-For and Via.

Frequently asked questions

Is a 502 a client or a server problem?

A server-side problem. The proxy or gateway could not get a valid response from the server behind it. In our test the proxy was fine; the upstream was stopped, reset the socket, or sent invalid HTTP.

Does fetch() throw on a 502?

No. In Node 24, fetch() resolved with status 502, statusText "Bad Gateway" and ok false. You must check res.ok or res.status. fetch() only throws on network failures, like a body cut off mid-stream (TypeError: terminated).

Why did my client get a 200 with a truncated body instead of a 502?

Because the upstream crashed after the proxy had already forwarded the status and headers. In our test curl showed 200 OK, 7 of 100 bytes, and exit code 18. The proxy can only drop the connection at that point.

What does nginx 'connect() failed (111: Connection refused)' mean?

From the docs: errno 111 is ECONNREFUSED. Nothing accepted the connection on the address in proxy_pass. Start the app, fix the port, or bind the app to 0.0.0.0 if it runs in a container.

What is the difference between 502 and 504?

502 means the upstream answered badly or not at all (refused, reset, invalid). 504 Gateway Timeout means the proxy gave up waiting for an upstream that was still connected but too slow.

Written with AI assistance. Measured on 2026-10-02 with curl 8.14.1 and Node.js 24.21.0: a small Node reverse proxy (plain http module) on 127.0.0.1:8983 in front of a Node upstream on 127.0.0.1:8984 and a raw TCP server on 127.0.0.1:8985. We tested a stopped upstream, a reset socket before and during the response, and a non-HTTP reply. The nginx, php-fpm and Docker notes are from the docs, not measured. PlainHeaders is our extension; curl and Node.js are free.