You know the error. A service that has been healthy for months starts throwing read ECONNRESET a handful of times an hour. Or Error: socket hang up. Or, if you are on Node's built-in fetch, the maddeningly vague TypeError: fetch failed with a cause buried three levels deep that reads SocketError: other side closed. It never reproduces locally. It survives every retry of your test suite. It hits maybe 0.01% of requests — which at production volume is dozens of angry log lines a day and, if one of those requests was a payment or a signup, a support ticket.
The most-viewed Stack Overflow threads on these errors have millions of views and dozens of answers between them, proposing everything from firewall rules to disk space. Almost none of them describe the mechanism that actually produces the intermittent production flavor of this error. It is not a network glitch, and it is not random. It is a race condition between your HTTP connection pool and the other side's idle timeout, and once you see it you can fix it deliberately instead of pasting retry loops and hoping.
What actually causes intermittent ECONNRESET in Node.js
Intermittent ECONNRESET in Node.js is a keep-alive race: your HTTP client reuses a pooled TCP connection at the same moment the server or load balancer closes it for being idle, so the request lands on a socket the other side has already torn down and the kernel replies with a TCP reset. Unpacking that: your HTTP client keeps idle TCP connections open so the next request can skip the handshake — that is keep-alive pooling, and every serious client does it. The server on the other side (or a load balancer in between) also has an idle timer, and closes connections it considers dead. The race: the server decides to close an idle connection at the exact moment your client picks that same connection out of the pool and writes a new request onto it. The close and the reuse cross on the wire. Your request lands on a socket the other side has already torn down, the kernel answers with a TCP reset, and your application sees ECONNRESET or socket hang up — for a request that was perfectly formed and a connection that was healthy a second ago.
Three properties of this race explain everything that makes it infuriating:
- It is probabilistic. The window is milliseconds wide, at the boundary of an idle timeout. Low traffic barely ever hits it; production traffic rolls those dice thousands of times an hour.
- It is invisible locally. On your machine there is no load balancer, your requests are seconds apart rather than perfectly spaced around an idle boundary, and your dev server's timeouts never get a chance to matter.
- It is nobody's bug. The client behaved correctly. The server behaved correctly. TCP has no way to say "I am about to close this, do not use it" — a FIN can always cross an in-flight request. The race is inherent to keep-alive over TCP, which is why Node core's own patch for it is titled "reduce likelihood of race conditions on keep-alive timeout". Reduce. Not eliminate.
keepAliveTimeout defaults: Node.js, AWS ALB, nginx, and undici
The race has two seats: the side that owns the idle timer and closes the connection, and the side that reuses pooled connections. Whoever closes first, wins — and if the reuser's idea of "still alive" outlives the owner's idea of "idle, kill it," you get resets. These are the defaults actually in play in a typical Node.js stack:
| Component | Role | Idle timeout default | What happens at the boundary |
|---|---|---|---|
| Node.js http.Server | closes idle sockets | keepAliveTimeout: 5 seconds | Closes a socket 5s after the last response — often before the load balancer in front of it expects |
| AWS ALB / ELB | reuses backend connections | 60 seconds | Pools connections to your Node server and reuses them for up to 60s |
| nginx (as proxy) | both roles | keepalive_timeout: 75s downstream, own pool upstream | Different defaults on each side of it |
| undici / Node fetch (client) | reuses pooled sockets | keepAliveTimeout: 4 seconds | Also subtracts a 1s safety threshold from any server-advertised Keep-Alive hint |
| new http.Agent() (manual) | reuses pooled sockets | keepAlive: false unless you enable it | No pooling — slower, but immune to this race |
Why an ALB returns 502 Bad Gateway in front of Node.js
Read the first two rows together and you can see the classic production incident: an ALB happily holds a connection to your Node server for up to 60 seconds, but Node kills it after 5 seconds of idleness. Every request the ALB sends down a connection that Node just closed becomes a 502 Bad Gateway for one user. This is not hypothetical — it is the mismatch AWS's own documentation warns about, and the first thing to check when a Node service behind an ALB throws intermittent 502s. On Kubernetes the same arithmetic repeats at every extra hop — ingress controller and service mesh each add an idle timer of their own to align, which is why we audit them as a set in our Kubernetes consulting engagements.
The rule that falls out of the table: at every hop, the side that hands out pooled connections must give up on them before the side that owns them closes them. Two concrete applications:
01// Node server behind a load balancer: outlive the LB's idle timeout.02// ALB default is 60s — go just past it, and keep headersTimeout above both.03const server = app.listen(port);04server.keepAliveTimeout = 65_000;05server.headersTimeout = 66_000;Notice that undici — the engine under Node's global fetch — already plays defense on the client side: it stops trusting idle pooled sockets after 4 seconds, and when a server advertises Keep-Alive: timeout=5, undici subtracts a one-second threshold and stops using the socket at 4. That is a well-designed mitigation, and the reason fetch hits this race far less often than old hand-rolled agents. It is still a timer-based guess. Under event-loop lag or an aggressive server, the guess is occasionally wrong, which is exactly the residue of UND_ERR_SOCKET errors you see in production.

Why you cannot reproduce it (and how to force it)
The precondition is a connection sitting idle right at the far side's timeout boundary when a new request grabs it. Locally you never manufacture that timing: your upstream is a dev server with generous timeouts, there is no LB, and your request spacing is human-random rather than boundary-aligned.
So force it. Make the window enormous instead of milliseconds wide:
01// Deterministic repro: make the server's idle window tiny,02// then reuse a pooled connection just after it closes.03const http = require('http');0405const server = http.createServer((req, res) => res.end('ok'));06server.keepAliveTimeout = 1_000; // 1s idle window07server.listen(4000);0809const agent = new http.Agent({ keepAlive: true });10const call = () => new Promise((resolve, reject) =>11 http.get({ port: 4000, agent }, res => res.resume().on('end', resolve))12 .on('error', reject));1314await call(); // opens + pools the socket15await new Promise(r => setTimeout(r, 1_500)); // idle past the 1s boundary16await call(); // ECONNRESET territoryRun that a few times and you will catch ECONNRESET reliably. The point is not the demo — it is that once the failure is deterministic, you can verify your fix instead of deploying and watching logs for a week. If you already ship distributed traces, you can also see the race in the wild: the failed span shows a connection reused microseconds after the upstream's idle boundary. Our zero-code OpenTelemetry setup for Node.js takes about an afternoon and makes this class of bug visible permanently.
Is it safe to retry ECONNRESET? Yes, for idempotent requests
Tuning the numbers is worth doing — it turns dozens of errors an hour into a handful a week. But the window never reaches zero, because TCP cannot make "close" and "reuse" atomic. The complete fix has been in the HTTP spec for a decade — RFC 7230 §6.3.1, carried forward into RFC 9110 §9.2.2:
"When an inbound connection is closed prematurely, a client MAY open a new connection and automatically retransmit an aborted sequence of requests if all of those requests have idempotent methods."
The reasoning matters more than the permission. In this race, the far side closed the connection before your request was ever processed — the application never saw it. Retrying cannot double-charge anyone, because nothing happened the first time. That is why every mature HTTP stack (browsers included) silently retries idempotent requests on a stale-connection close, and why your Node service should too:
- GET, HEAD, PUT, DELETE, OPTIONS — idempotent by definition. Retry once on
ECONNRESET/socket hang up/other side closed/ERR_STREAM_PREMATURE_CLOSE, on a fresh connection. - POST — not idempotent, so the spec says never retry automatically... unless you know better. If your POST carries an idempotency key that the server deduplicates, it is safe. This is exactly why payment APIs make you send
Idempotency-Keyheaders — they are making POST retry-safe on purpose. - Never retry a retry. One reconnect attempt, then surface the error. A stale connection fails instantly; a genuinely down service fails repeatedly, and hammering it makes the outage worse.
01const IDEMPOTENT = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);02const STALE_CODES = new Set(['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET', 'ERR_STREAM_PREMATURE_CLOSE']);0304async function request(url, opts = {}, attempted = false) {05 try {06 return await fetch(url, opts);07 } catch (err) {08 const code = err.cause?.code ?? err.code;09 const method = (opts.method ?? 'GET').toUpperCase();10 const retriable = IDEMPOTENT.has(method) || opts.idempotencyKey;11 if (!attempted && retriable && STALE_CODES.has(code)) {12 return request(url, opts, true); // fresh socket, one retry only13 }14 throw err;15 }16}Eight lines of policy, and the race stops paging you. Note what this is not: it is not a generic retry-with-backoff wrapper around every call. Retrying timeouts or 500s blindly is how you turn a hiccup into a thundering herd. This retries exactly one failure class — the one where the spec guarantees the server never saw your request.
One adjacent trap while you are in this part of the codebase: a request that dies on a stale connection returns instantly, but the symptoms of connection problems often show up somewhere else first — as pool exhaustion. If your service holds database or HTTP connections while hung requests pile up, you get a second wave of errors that looks unrelated. We wrote about that failure chain in running LangGraph's Postgres checkpointer in production, where the connection pool was the first thing to buckle.
A checklist for your stack
- Behind a load balancer? Set your Node server's
keepAliveTimeoutabove the LB's idle timeout (ALB: 60s → use 65s), andheadersTimeoutabove that. This kills the 502s. - Calling third-party APIs? Prefer Node's built-in
fetch/undici over legacy agents — its 4-second pool timeout plus the 1-second threshold already dodges most stale sockets. - Add the idempotent-retry policy above at your HTTP client boundary. One retry, idempotent methods only, stale-connection error codes only.
- Make POSTs retry-safe where they matter, with idempotency keys your backend deduplicates.
- Trace it. A span that fails in under a millisecond on a reused connection is this race; a span that fails after 30 seconds is a timeout and a different article.
If you would rather have someone do this end to end — timeout audit across every hop, retry policy, and the tracing to prove it worked — this is bread-and-butter work for our DevOps and cloud engineering team, and it usually fits in a week alongside a broader web application health review.
FAQ
What causes ECONNRESET in Node.js?
ECONNRESET means the other side of a TCP connection sent a reset instead of a clean close. In production Node services the dominant cause is the keep-alive race: your client reused a pooled connection at the same moment the server or load balancer closed it as idle. Other causes — crashed upstreams, firewalls cutting long-lived connections, oversized payloads — exist, but they are not intermittent-at-low-rate the way the race is.
What does "socket hang up" mean in Node.js?
Error: socket hang up is raised by Node's HTTP client when a connection closes before any response headers arrive. In production it is usually the same keep-alive race as ECONNRESET: the request went out on a pooled socket the other side had already closed. The fix is identical — align timeouts at every hop, then retry idempotent requests once on a fresh connection.
Why does it only happen in production and never locally?
The race needs a connection to be reused within milliseconds of the other side's idle-timeout boundary. That takes real traffic volume, real load balancers with their own timers, and steady request spacing — none of which exist on a laptop. You can force it locally by shrinking the server's keepAliveTimeout to one second and reusing a pooled connection just after it expires.
Can I retry a POST that failed with ECONNRESET?
For idempotent methods — GET, HEAD, PUT, DELETE, OPTIONS — yes, once, on a fresh connection; RFC 7230 explicitly permits it because the server never processed the request. For POST, only if the request carries an idempotency key the server deduplicates. Never retry automatically more than once.
What should keepAliveTimeout be set to?
On a Node server behind a load balancer: a few seconds longer than the load balancer's idle timeout, so the LB always gives up on a connection before Node closes it. Behind an AWS ALB with its 60-second default, 65 seconds is the standard choice, with headersTimeout set just above that. A Node server facing the open internet can keep the 5-second default.
What does "TypeError: fetch failed" with "other side closed" mean?
It is the modern surface of the same race. Undici (the engine behind fetch) stops trusting idle pooled sockets after 4 seconds and honors server Keep-Alive hints minus a safety margin, which prevents most stale reuse. The residue surfaces as TypeError: fetch failed with a SocketError: other side closed cause — same race, same fix: idempotent retry.
Intermittent network errors are the cheapest reliability win most teams are sitting on: one afternoon of timeout alignment and one retry policy, and a whole error class disappears from your logs. If your service throws errors you cannot explain — or you want the tracing in place so the next mystery explains itself — talk to us. We do this for production Node.js systems every month.

