# 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.

Source: https://howhttpworks.com/debug/err-connection-reset
Last reviewed: 2026-10-04

Error messages this page covers:
- `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`

> **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)").

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

The same event in other clients:

```text
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 happened | Chrome | curl | Node.js |
|---|---|---|---|
| RST before any response | `ERR_CONNECTION_RESET` | `(56) Recv failure: Connection reset by peer` | `read ECONNRESET` |
| FIN before any response | `ERR_EMPTY_RESPONSE` | `(52) Empty reply from server` | `socket hang up` (code `ECONNRESET`) |
| FIN partway through a body with `Content-Length` | `ERR_CONTENT_LENGTH_MISMATCH` | `(18) transfer closed with N bytes remaining to read` | `aborted` |
| FIN during the TLS handshake | `ERR_CONNECTION_CLOSED` | `(35) ... SSL_ERROR_SYSCALL` or `unexpected eof while reading` | `Client 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:

```bash
# Linux; on macOS use -i en0 (or lo0 for local tests)
sudo tcpdump -ni any -v 'tcp[tcpflags] & tcp-rst != 0 and port 443'
```

```text
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](https://howhttpworks.com/status-codes/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:

```bash
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](https://howhttpworks.com/glossary/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](https://howhttpworks.com/debug/nginx-502-bad-gateway):

  ```javascript
  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:

  ```nginx
  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.

  ```javascript
  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](https://howhttpworks.com/glossary/idempotent).
- **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:

```text
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](https://howhttpworks.com/debug/nginx-413-request-entity-too-large).
- 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:

```bash
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](https://howhttpworks.com/status-codes/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:

```bash
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](https://howhttpworks.com/debug/err-ssl-protocol-error).

## Reproduce and verify

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

```javascript
// 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)
```

```bash
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:

```bash
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'
```

## Related

- [nginx 502 Bad Gateway](https://howhttpworks.com/debug/nginx-502-bad-gateway): what a proxy turns an upstream reset into, including the ALB and Node keepalive race.
- [Keep-alive](https://howhttpworks.com/glossary/keep-alive) and the [Connection header](https://howhttpworks.com/headers/connection): how persistent connections are negotiated and closed.
- [nginx 413](https://howhttpworks.com/debug/nginx-413-request-entity-too-large): the body limit behind most upload resets.
- [444 Connection Closed Without Response](https://howhttpworks.com/status-codes/444): nginx closing on purpose, optionally with an RST.
- [ERR_SSL_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-ssl-protocol-error): handshake failures that end in a TLS alert rather than a reset.
- [ERR_HTTP2_PROTOCOL_ERROR](https://howhttpworks.com/debug/err-http2-protocol-error): an HTTP/2 stream reset by the peer, often from headers that are illegal in HTTP/2.
- [ERR_CONNECTION_REFUSED](https://howhttpworks.com/debug/err-connection-refused): the connection was rejected before it existed, including localhost, Docker and Kubernetes cases.
- [ERR_CONNECTION_TIMED_OUT](https://howhttpworks.com/debug/err-connection-timed-out): no reply at all, from silent firewall drops, stale addresses, broken IPv6 or a full listen backlog.
- [DNS_PROBE_FINISHED_NXDOMAIN](https://howhttpworks.com/debug/dns-probe-finished-nxdomain): the name never resolved, so no connection was attempted.
