open atlas
↑ Back to track
Node.js, zero to senior NODE · 10 · 03

Sockets in production: timeouts, limits, and fd exhaustion

Production socket failures are predictable: idle sockets without timeouts pin file descriptors, ECONNRESET/EPIPE crash unhardened handlers, and an unbounded accept rate exhausts fds. Add timeouts, limits, and a graceful drain.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

At 3 a.m. a service stops accepting connections. CPU is near zero, memory is fine, no errors in the logs — it just hangs. The on-call engineer runs lsof -p <pid> | wc -l and sees 65,535 open file descriptors. The culprit: clients that opened a socket, sent nothing, and went away — half-open or silently dead connections with no timeout. Each one held a file descriptor forever. The process never crashed; it suffocated. Every socket the kernel hands you is a finite resource, and in production the question is never if a client misbehaves but when.

Idle sockets need a timeout or they pin a file descriptor forever

The 3 a.m. incident in the hook is reproducible in any Node server that ships without three deliberate defenses. Here is how each failure mode works and exactly what stops it.

A TCP socket, once accepted, occupies a file descriptor — a finite, per-process kernel resource (often defaulting to 1,024 or capped by ulimit -n). Node does not impose an idle timeout by default: a socket that connects and then goes silent will sit open indefinitely. A misbehaving or dead client — one that crashed without a clean close, or a half-open connection where the FIN never arrived — therefore leaks a descriptor that is never reclaimed. Accumulate enough and the process can no longer accept() anything: the hang in the hook.

The defense is socket.setTimeout(ms), which fires a 'timeout' event after ms of inactivity (no reads or writes). Crucially, the timeout event does not close the socket for you — it just notifies you — so you must act:

server.on("connection", (socket) => {
  socket.setTimeout(30_000);                 // 30s idle budget
  socket.on("timeout", () => {
    socket.destroy(new Error("idle timeout")); // YOU must close it
  });
  socket.on("error", () => {});               // swallow post-destroy errors
});

For HTTP servers, the same idea is exposed as server.setTimeout(), plus server.headersTimeout, server.requestTimeout, and server.keepAliveTimeout — the last guards the gap between keep-alive requests. The Slowloris attack is precisely the weaponized version of the hook: an attacker opens many connections and dribbles bytes slowly to keep each just under any naive timeout, exhausting your connection capacity with almost no bandwidth. Header and request timeouts are what stop it.

Keep-alive: reuse the handshake, but bound the idle

Keep-alive is the reuse of one TCP connection for many requests, avoiding a fresh handshake (and TLS negotiation) each time — a large latency win. But an idle kept-alive connection still holds a file descriptor and a slot. The tuning tension is real: too short a keepAliveTimeout and you throw away the reuse benefit, reconnecting constantly; too long and idle clients hoard descriptors you could serve to active ones.

There is also a notorious race between a server closing an idle keep-alive connection and a client sending one more request on it: the request lands on a socket the server is tearing down, producing an ECONNRESET the client sees as a failed request. The mitigation is to make the server’s keepAliveTimeout longer than the upstream load balancer’s idle timeout, so the LB always closes first, on a connection it knows is idle — a classic source of intermittent 502s when reversed.

Why this works

Why must the server’s keep-alive timeout exceed the load balancer’s? Whoever closes an idle connection wins the race cleanly; whoever sends on a connection the other side just closed loses with a reset. If the LB’s idle timeout is shorter, the LB sends a request onto a connection your server has already started closing, and the client gets a 502/ECONNRESET. Make the server outlast the LB and the LB always initiates the close on a genuinely idle socket — no in-flight request to reset.

ECONNRESET and EPIPE are not bugs — they are Tuesday

Two error codes dominate production socket logs, and treating them as exceptional is a mistake.

ErrorWhat happenedRight response
ECONNRESETPeer sent RST — abrupt close (crash, kill, network drop)Clean up this connection; do not crash the process
EPIPEYou wrote to a socket the peer already fully closedStop writing; the connection is gone

Both are emitted as 'error' events on the socket, and the iron rule from the TCP lesson applies with force in production: an unhandled socket 'error' throws and takes down the entire process, killing every other in-flight connection with it. One misbehaving client must never be able to crash a server serving thousands. So every socket — accepted server-side and opened client-side — needs an 'error' handler that logs-and-cleans-up rather than rethrows. The asymmetry to internalize: ECONNRESET is something done to you (the peer reset), EPIPE is you writing into a closed pipe — but both resolve to “this one connection is dead, release it, keep serving everyone else.”

Bound connections, then drain gracefully

The hook’s root cause was unbounded resource growth. server.maxConnections caps concurrent sockets: past the limit, the server stops accepting (the OS backlog absorbs a few, then refuses), which sheds load instead of collapsing into fd exhaustion. It is a blunt instrument but a real backstop. Pair it with an OS-level ulimit -n raised to a deliberate number and you have an upper bound you chose rather than one you discover at 3 a.m.

The other half is graceful shutdown. On SIGTERM (a deploy, a scale-down), you must drain rather than drop: server.close() stops accepting new connections and fires its callback once all existing ones finish — but it will wait forever on a long-lived idle keep-alive socket, so you also iterate active sockets, signal them to finish, and destroy() any stragglers after a deadline. Skipping this turns every deploy into a wave of ECONNRESETs for in-flight users.

Pick the best fit

Your Node server sits behind a load balancer and you see intermittent 502/ECONNRESET on otherwise healthy requests.

Quiz

A long-running Node service slowly stops accepting new connections with no crash, no high CPU, and no memory growth. Most likely cause?

Quiz

socket.setTimeout(30000) fires a 'timeout' event but the connection stays open and keeps holding its fd. Why?

Recall before you leave
  1. 01
    Why can a Node server run out of file descriptors with no crash, no CPU spike, and no memory growth — and how do you prevent it?
  2. 02
    Why must you handle ECONNRESET/EPIPE on every socket, and what does graceful shutdown add?
Recap

Production socket failures are not random — they are three recurring disciplines you either apply or pay for. First, time out idle sockets: every accepted socket pins a finite file descriptor, Node sets no default idle timeout, so socket.setTimeout(ms) plus an explicit destroy() in the 'timeout' handler is what reclaims descriptors leaked by dead or half-open clients — and HTTP’s headersTimeout/requestTimeout are what defeat Slowloris. Second, treat ECONNRESET and EPIPE as routine: both surface as socket 'error' events, and an unhandled one crashes the whole process and every connection on it, so every socket needs an error handler that cleans up the one connection. Third, bound and drain: server.maxConnections and a deliberate ulimit -n cap growth so you never silently exhaust descriptors, and server.close() with active-socket draining on SIGTERM turns deploys from reset storms into clean handoffs. Tune keep-alive between reuse and hoarding, and make the server’s keepAliveTimeout outlast the load balancer’s to kill the intermittent-502 race. Bound the resource, time out the idle, handle the inevitable error, drain on the way out. Now when you see a Node service that “hangs” with no crash and no high CPU, your first move is lsof -p <pid> | wc -l — and you know exactly which three missing lines caused it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.