Node.js ECONNRESET — Keep-Alive Socket Closed by the Server
Fix read ECONNRESET and socket hang up in Node.js by tracing reused keep-alive sockets, aligning agent and server timeouts, and retrying only idempotent requests.
Symptoms
- Error: read ECONNRESET at TCP.onStreamRead
- socket hang up from an HTTP request that usually succeeds
- Failures appear on the first request after a period of inactivity
- Only some requests through a proxy or load balancer fail with ECONNRESET
Possible causes
- The server or proxy closed an idle keep-alive connection while the client agent still pooled it
- Node's server keepAliveTimeout is shorter than the client's socket reuse window
- The upstream process crashed, was restarted, or ran out of file descriptors
- A response body was not consumed, so the socket was never returned to the pool cleanly
- A middlebox reset the idle TCP flow without a graceful close
Diagnosis steps
Log the error code, syscall, and whether the socket was reused
node -e "const http=require('http'); const req=http.get('http://127.0.0.1:3000/health', r=>{r.resume(); r.on('end',()=>console.log('ok'))}); req.on('error',e=>console.log(e.code,e.syscall,'reused=',req.reusedSocket))"Interpretation
reused=true shows a pooled keep-alive socket died before the request. reused=false points to a fresh connection being rejected or reset during connect.
Compare client and server keep-alive timeouts
grep -R "keepAliveTimeout\|headersTimeout" --include='*.js' .; node -e "console.log(require('http').globalAgent.options)"Interpretation
If the client reuse window or reverse proxy idle timeout is longer than the server keepAliveTimeout, the client will reuse sockets the server has already closed.
Reproduce after an idle interval
sleep 65; curl -sv -o /dev/null http://127.0.0.1:3000/health 2>&1 | tail -20Interpretation
A 502 or reset after the idle wait confirms the connection was dropped by an idle timeout rather than by request handling.
Inspect sockets and upstream health at failure time
ss -tanp | grep -E ':3000|:80 ' | head -20Interpretation
CLOSE-WAIT accumulation suggests the application is not closing sockets, while an empty upstream port list suggests the process is down.
Root cause
An HTTP/1.1 keep-alive connection was closed by the peer, normally an idle timeout or a restart, while the Node.js agent still considered it reusable. The client wrote a request into a socket that had already received a FIN or RST, so the request failed with ECONNRESET instead of transparently opening a new connection.
Fix
Enable keep-alive on the client agent and set its reuse window below the server and proxy idle timeouts. Configure server.keepAliveTimeout and headersTimeout explicitly, consume every response body, and retry idempotent GET or HEAD requests once on ECONNRESET. Align or disable proxy idle timeouts on the path.
Risk note
Automatically retrying POST or PUT can duplicate side effects; only retry requests that are safe to repeat. Raising keepAliveTimeout holds sockets longer and can exhaust file descriptors under high concurrency. Disabling keep-alive entirely removes the failure mode but adds a TCP and TLS handshake to every request.
Verify the fix
Idle a pooled connection past the proxy timeout, then send a request and confirm it succeeds on a new socket without ECONNRESET. Under sustained load check that freeSockets stays bounded, response bodies are consumed, and upstream restarts no longer produce client errors.
A pooled socket died before reuse
read ECONNRESET means the peer sent a TCP reset, or the socket was already closed when Node wrote to it. In an HTTP client the usual sequence is: the agent keeps an idle keep-alive connection, the server or proxy closes it after its idle timeout, and the next request reuses the dead socket.
Error: read ECONNRESET
at TCP.onStreamRead (node:internal/stream_base_commons:190:20)
Node’s ClientRequest exposes reusedSocket, which separates this from a fresh connection failing during connect. If reusedSocket is true, the timeout alignment is the first suspect.
Align timeouts on both sides
The client reuse window, the reverse proxy idle timeout, and the Node server’s keepAliveTimeout must be ordered. A common safe arrangement is server keepAliveTimeout greater than the proxy idle timeout, which is in turn greater than the client reuse window.
const http = require('http');
const agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 15_000,
maxSockets: 50,
});
const server = http.createServer(app);
server.keepAliveTimeout = 61_000; // longer than the proxy idle timeout
server.headersTimeout = 65_000; // must exceed keepAliveTimeout
A server that never consumes the response body leaves the socket unusable. Call res.resume() when the body is not needed, and always handle the error event on both request and response.
Retry only what is safe
A GET that fails before any response bytes are received can be retried once with jitter. A POST that may have reached the handler cannot, unless the operation is idempotent through a request key. Blind retries turn one reset into duplicate orders.
Verify
Reproduce with an idle wait longer than the proxy timeout, then issue a request through the same agent. It should open a new connection and succeed. During a soak test, watch agent.freeSockets and the server’s socket count for unbounded growth, and confirm a rolling restart of the upstream only produces retried-successful requests, not user-visible ECONNRESET errors.