How HTTP Works

Debug guide · you're seeing

  • This site can't be reached. The connection was reset. ERR_CONNECTION_RESET
  • curl: (56) Recv failure: Connection reset by peer
  • Error: read ECONNRESET
  • Error: socket hang up
  • The connection was reset. The connection to the server was reset while the page was loading.
  • ConnectionResetError: [Errno 104] Connection reset by peer
  • java.net.SocketException: Connection reset

ERR_CONNECTION_RESET and ECONNRESET: Find the RST

Fix ERR_CONNECTION_RESET, ECONNRESET and curl (56) Connection reset by peer: keep-alive races, body limits, firewalls, crashes. Find who sent the RST.

Reviewed 11 min readintermediate9 sourcesTry itMarkdown
On this page

TL;DR: Something sent a TCP RST and killed the connection mid-flight. The usual suspects, in order: a keep-alive race where the client reuses a connection the server just closed, a request body over the server’s limit, a firewall, NAT or IDS resetting the flow, and a process dying with unread data. Capture with tcpdump -ni any 'tcp[tcpflags] & tcp-rst != 0' on both ends to see which hop sent it.

What it means

A TCP connection ends one of two ways. A FIN is an orderly “I’m done sending”; an RST is “abort this connection now”, and the receiving kernel throws away whatever was buffered, including a response that had already arrived but had not been read yet. ERR_CONNECTION_RESET is Chrome’s name for the second case (net error -101, “a connection was reset (corresponding to a TCP RST)”).

This site can't be reached
The connection was reset.
ERR_CONNECTION_RESET

The same event in other clients:

Firefox:  The connection was reset
          The connection to the server was reset while the page was loading.
curl:     curl: (56) Recv failure: Connection reset by peer
          curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to example.com:443
                                                                (RST during the TLS handshake)
Node.js:  Error: read ECONNRESET  { errno: -104, code: 'ECONNRESET', syscall: 'read' }
          (errno is -54 on macOS)
fetch:    TypeError: fetch failed  [cause]: Error: read ECONNRESET
Python:   ConnectionResetError: [Errno 104] Connection reset by peer
          requests: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))
Go:       read tcp 10.0.0.5:51234->203.0.113.10:443: read: connection reset by peer
Java:     java.net.SocketException: Connection reset
nginx:    recv() failed (104: Connection reset by peer) while reading response header from upstream

FIN-based closes look different, and telling them apart halves the search space:

What happenedChromecurlNode.js
RST before any responseERR_CONNECTION_RESET(56) Recv failure: Connection reset by peerread ECONNRESET
FIN before any responseERR_EMPTY_RESPONSE(52) Empty reply from serversocket hang up (code ECONNRESET)
FIN partway through a body with Content-LengthERR_CONTENT_LENGTH_MISMATCH(18) transfer closed with N bytes remaining to readaborted
FIN during the TLS handshakeERR_CONNECTION_CLOSED(35) ... SSL_ERROR_SYSCALL or unexpected eof while readingClient network socket disconnected before secure TLS connection was established

Watch the Node row: socket hang up carries the code ECONNRESET even when the server closed cleanly, so err.code === 'ECONNRESET' alone does not prove an RST was sent.

Who sent it?

The RST tells you nothing about why, but where it came from narrows things fast. Capture only resets, on the client and on the server at the same time:

# Linux; on macOS use -i en0 (or lo0 for local tests)
sudo tcpdump -ni any -v 'tcp[tcpflags] & tcp-rst != 0 and port 443'
10:12:01.204512 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 40)
    203.0.113.10.443 > 10.0.0.5.51234: Flags [R], cksum 0x1c2a (correct), seq 2873450123, win 0, length 0

Read it like this:

  • The server’s capture shows it sending the RST. The server application or its kernel decided. Go to causes 1, 2 and 4 below.
  • The client receives an RST that the server’s capture never shows. Something in between generated it: a load balancer, NAT gateway, firewall, IDS or proxy. Go to cause 3.
  • The RST’s TTL does not match the server’s normal packets. Compare with the ttl on the SYN-ACK from the same connection. An RST that arrives with a TTL a dozen hops different from the server’s own packets was almost certainly injected by a middlebox.
  • The RST comes from the client side. Clients reset too, for example when a browser tab is closed mid-download. nginx then logs a 499 for the request.

In Wireshark, filter with tcp.flags.reset == 1, then right-click the packet and use Follow > TCP Stream to see what was exchanged just before the reset. Add ip.ttl as a column to spot injected resets.

On a Linux server, the kernel counts resets even without a capture:

nstat -az TcpOutRsts TcpEstabResets TcpExtTCPAbortOnClose TcpExtTCPAbortOnData

TcpExtTCPAbortOnClose increases when an application closes a socket that still has unread data, which makes the kernel send an RST. If it climbs with your errors, your own server is resetting connections it did not finish reading (causes 2 and 4). TcpEstabResets counts RSTs received on established connections.

Fix it, in order of likelihood

1. Keep-alive idle timeout races

The classic intermittent ECONNRESET. HTTP/1.1 keeps connections open for reuse (keep-alive). Each side has its own idle timer. When the server’s timer fires at the same moment the client picks that idle connection from its pool and writes a request, the request lands on a socket the server has already closed, and the server’s kernel answers with an RST. It shows up as rare, random failures under light or bursty traffic, and never in a load test.

The rule: the side that reuses connections must give up on idle connections before the side that accepts them does. In practice the server’s idle timeout must be longer than the client’s or proxy’s.

  • Node.js server behind a load balancer. server.keepAliveTimeout is 5 seconds by default (still the case in Node 26), while an AWS ALB keeps idle connections for 60 seconds. The ALB reuses a connection Node has closed, gets an RST and returns 502 to the user. This is the same race covered under keepalive mismatch in nginx 502 Bad Gateway:

    const server = app.listen(3000)
    server.keepAliveTimeout = 65_000   // longer than the ALB idle timeout (60s)
    server.headersTimeout = 66_000     // keep above keepAliveTimeout
  • nginx with upstream keepalive. nginx closes idle upstream connections after the upstream keepalive_timeout (60 seconds by default). Keep it below the application’s idle timeout, and enable keepalive correctly:

    upstream app {
        server 127.0.0.1:3000;
        keepalive 32;
        keepalive_timeout 30s;   # shorter than the app's idle timeout
    }
    
    server {
        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
        }
    }
  • Node.js as the client. Since Node 19, http.globalAgent has keepAlive: true with a 5 second socket timeout, so a Node client talking to a server that also drops idle connections after about 5 seconds hits the race regularly. Node’s docs show the fix for idempotent requests: retry when the failure happened on a reused socket.

    const req = http.get('http://api.internal/health', onResponse)
    req.on('error', (err) => {
      if (req.reusedSocket && err.code === 'ECONNRESET') {
        // The pooled connection died under us; safe to retry a GET once
        retry()
      }
    })

    Do not blindly retry POST this way: the server may have processed the request before the connection died. See idempotent methods.

  • Long idle connections through AWS NAT gateways. A NAT gateway drops a connection after 350 seconds of inactivity, and when a client behind it tries to use that connection again, the NAT gateway returns an RST (not a FIN). Database pools and long-lived HTTP clients behind NAT hit this overnight. Send traffic more often, or set TCP keepalive below 350 seconds; Linux’s default net.ipv4.tcp_keepalive_time is 7200 seconds, far too long. Network Load Balancers behave the same way at their own idle timeout (350 seconds by default for TCP).

2. A request body larger than the server’s limit

The server reads the headers, sees a Content-Length over its limit, sends 413 and closes, while the client is still uploading. RFC 9112 section 9.6 describes exactly what goes wrong next: data arriving on a closed connection makes the server’s TCP stack send a reset, and “the reset packet might erase the client’s unacknowledged input buffers before they can be read”. The 413 was sent, but the browser never sees it and shows ERR_CONNECTION_RESET. nginx’s own documentation for client_max_body_size (default 1m) warns that “browsers cannot correctly display this error”.

The nginx error log still records it:

client intended to send too large body: 20971520 bytes

nginx softens this with lingering_close on (the default): when more client data may still be coming, it keeps reading and discarding it for up to lingering_time (30s) before closing, so the client usually gets to read the 413. Uploads that are still going after 30 seconds get the RST anyway. Fixes:

  • Raise the limit where the uploads go, at every layer (nginx client_max_body_size, ingress-nginx nginx.ingress.kubernetes.io/proxy-body-size, the application’s own parser limit). See nginx 413.
  • Check the file size in the browser before uploading, so users get a real message.
  • For very large files, upload directly to object storage with a presigned URL instead of through your app.

Application servers that close the socket without reading the rest of the body cause the same reset, which shows up as TcpExtTCPAbortOnClose on the server.

3. A firewall, IDS, WAF or NAT resetting the flow

Network devices reset connections they decide to stop: an IPS signature match, a firewall policy whose action is “reset” rather than “drop”, a session table entry that expired, or a NAT mapping that timed out. Signs that this is your cause:

  • The server-side capture has no RST, but the client receives one (see “Who sent it?”).
  • Failures correlate with a payload (a particular file, a URL containing SQL-like text), a destination, or a time limit (connections dying at a fixed age or idle time).
  • The error appears on one network (office VPN, a customer’s site) and not on another.

Fix it in the device’s policy or timeouts, not in the application. If the device is yours, its logs name the rule. If it belongs to a customer or an ISP, a pair of captures showing the RST arriving without being sent is the evidence they will ask for.

Kubernetes has a well-documented internal version of this. When conntrack on a node marks a returning packet as INVALID (for example, out of the TCP window under load), kube-proxy’s iptables rules do not translate it back to the Service IP. The client pod receives a packet from an address it never connected to, its own kernel answers with an RST, and the server pod sees the connection reset. The workaround from the Kubernetes write-up is to make conntrack accept such packets:

sysctl -w net.netfilter.nf_conntrack_tcp_be_liberal=1

4. A process crash or kill mid-response

When a server process dies, its kernel closes the process’s sockets. What the client sees depends on what was left on the socket:

  • No unread request data: the kernel sends a FIN. The client gets ERR_EMPTY_RESPONSE, curl (52) Empty reply from server, or (18) transfer closed with N bytes remaining to read if part of the body had been sent.
  • Unread request data in the receive buffer (the request body had not been read yet): the kernel sends an RST, and the client gets ERR_CONNECTION_RESET.

So a crash during a POST is more likely to look like a reset than a crash during a GET. Behind a proxy, the proxy absorbs it and the browser sees a 502 instead; nginx logs upstream prematurely closed connection (FIN) or recv() failed (104: Connection reset by peer) (RST). Look for the cause in the application’s own logs or the system’s: dmesg | grep -i 'killed process' for the OOM killer, kubectl describe pod for OOMKilled or a failed liveness probe, and worker timeouts such as Gunicorn’s WORKER TIMEOUT.

Resets can also be deliberate. nginx return 444; closes without a response, and with reset_timedout_connection on nginx sends an RST for 444 and for timed-out connections instead of a normal close. See 444.

5. TLS problems that surface as a reset

If the RST arrives during the TLS handshake, Chrome reports ERR_CONNECTION_RESET, not a TLS error, and curl fails with exit code 35 (OpenSSL SSL_connect: Connection reset by peer in connection to example.com:443 on OpenSSL builds, Recv failure: Connection reset by peer on others). If curl -v stops right after Client hello, the reset was a reaction to the ClientHello itself. Common causes:

  • A middlebox that cannot handle Chrome’s large ClientHello. Chrome’s post-quantum key share (X25519MLKEM768 since Chrome 131) pushes the ClientHello over one packet, and some firewalls and proxies that expected the whole ClientHello in one read reset the connection. If Chromium-based browsers fail while other clients on the same machine work, compare a small and a large ClientHello with OpenSSL 3.5 or later: openssl s_client -connect example.com:443 -servername example.com -groups X25519 against the same command with -groups X25519MLKEM768. If only the second one is reset, ask the device vendor for a fixed release.
  • SNI-based filtering. Firewalls and national filters that block by the hostname in the ClientHello often answer with an RST. The same IP works with another hostname: compare openssl s_client -connect IP:443 -servername blocked.example with a neutral name.
  • A server that resets instead of sending a TLS alert when it has no certificate or no shared cipher.

Test the handshake directly and see where it stops:

openssl s_client -connect example.com:443 -servername example.com </dev/null
# "write:errno=104" (Linux; 54 on macOS) means the RST came during the handshake

When the handshake completes but the browser shows a TLS alert instead, the page you want is ERR_SSL_PROTOCOL_ERROR.

Reproduce and verify

Reproduce a reset and a clean close locally to see what your client stack prints for each:

// rst-server.cjs: port 7001 resets, port 7003 closes cleanly
const net = require('node:net')
net.createServer((s) => s.once('data', () => s.resetAndDestroy())).listen(7001)
net.createServer((s) => s.once('data', () => s.end())).listen(7003)
node rst-server.cjs &
curl -sS http://127.0.0.1:7001/ -o /dev/null   # curl: (56) Recv failure: Connection reset by peer
curl -sS http://127.0.0.1:7003/ -o /dev/null   # curl: (52) Empty reply from server

socket.resetAndDestroy() sends an RST on purpose (Node 16.17 and 18.3 or later). Against the real system, verify a keep-alive fix by sending requests spaced just past the old idle timeout and confirming that the reset counters on the server stop climbing:

for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/health; sleep 6; done
watch -n 5 'nstat -z TcpExtTCPAbortOnClose TcpOutRsts'

Frequently asked questions

What does ERR_CONNECTION_RESET mean?

Chrome received a TCP RST packet on the connection, which aborts it immediately and discards anything still in flight. It is not an HTTP status: the server, a proxy, a load balancer, a firewall or the operating system tore down the TCP connection. Chrome uses ERR_CONNECTION_CLOSED or ERR_EMPTY_RESPONSE when the connection was closed normally with a FIN instead.

Why do I get ECONNRESET only on some requests in Node?

Intermittent ECONNRESET in an HTTP client is usually a keep-alive race: the client reuses a pooled connection at the moment the server closes it for being idle. Since Node 19 the global HTTP agent keeps connections alive by default, which made this common. Make the server's idle timeout longer than the client's, and retry idempotent requests when req.reusedSocket is true and the error code is ECONNRESET.

Does "socket hang up" mean the server sent a reset?

Not necessarily. Node reports "socket hang up" with code ECONNRESET when the connection closed before any response arrived, even if the server closed it cleanly with a FIN. "read ECONNRESET" is the one that means an actual TCP RST was received. Check a packet capture before blaming the network.

Why does a large upload show ERR_CONNECTION_RESET instead of 413?

The server rejected the body and closed the connection while the browser was still sending it. Data arriving on a closed socket makes the server's kernel send an RST, and the RST can wipe the 413 response out of the client's buffers before it is read. nginx documents that browsers cannot correctly display its 413. Raise client_max_body_size or check the file size before uploading.

How do I find out who sent the RST?

Capture on both ends with tcpdump -ni any 'tcp[tcpflags] & tcp-rst != 0'. If the server capture shows it sending the RST, the server or its kernel decided. If the client receives an RST the server never sent, a load balancer, NAT gateway, firewall or IDS in between generated it. A TTL on the RST that differs from the server's other packets is a strong hint it was injected along the way.

Sources

  1. RFC 9112 Section 9.6: Tear-downrfc-editor.org
  2. RFC 9293 Section 3.5.2: Reset Generation (TCP)rfc-editor.org
  3. MDN: Connection management in HTTP/1.xdeveloper.mozilla.org
  4. Chromium source: net_error_list.hsource.chromium.org
  5. nginx: ngx_http_core_module (client_max_body_size, lingering_close, reset_timedout_connection)nginx.org
  6. Node.js: http module (request.reusedSocket, server.keepAliveTimeout)nodejs.org
  7. AWS Docs: Troubleshoot NAT gateways (connection drops after 350 seconds)docs.aws.amazon.com
  8. Kubernetes blog: kube-proxy Subtleties, Debugging an Intermittent Connection Resetkubernetes.io
  9. Linux kernel docs: SNMP counters (TCPAbortOnClose, TcpEstabResets)docs.kernel.org

Keep going

Browse /search