Article View

Scroll down to read the full article.

The Ghost of TCP Past: Node.js keepAlive, ECONNRESET, and Ancient Linux

calendar_month July 24, 2026 |
Quick Summary: Debugging elusive ECONNRESET/EPIPE in Node.js http.Agent with keepAlive on older Linux kernels. Uncover the obscure firewall interaction causing c...

Alright, listen up. If you're here, it means you've been banging your head against the wall, probably muttering obscenities at your monitors. You're seeing intermittent ECONNRESET or EPIPE errors in your Node.js application, usually when hitting an external service. It's not consistent. It happens after periods of relative calm, then under peak load. You've checked everything. Logs are clean. Metrics look 'fine' until they aren't. Your stomach hurts. I've been there. Let's fix this.

This isn't your garden-variety network timeout. This is the kind of subtle, insidious bug that makes you question your career choices. It's a dance between older Linux kernel TCP stack behavior, specific Node.js http.Agent configurations, and an aggressive, rude network appliance somewhere in your infrastructure.

The Symptoms:

  • Your Node.js application uses http.Agent with keepAlive: true.
  • Requests sometimes fail with ECONNRESET or EPIPE, specifically on connections that were previously established and presumably 'kept alive'.
  • The errors are intermittent, often appearing after a period of low traffic, then manifesting under a sudden surge.
  • Direct connections (e.g., from your dev machine) work perfectly fine. The issue only appears in specific production/staging environments.
Ghostly TCP packet entangled in rusty network cables
Visual representation

The Environments Where This Hell Triggers:

This isn't an exhaustive list, but these are the usual suspects. The common denominator is an older kernel or a specific kernel patch level that doesn't handle TCP half-closure notifications (like EPOLLRDHUP) as gracefully or promptly as newer kernels.

Operating System Kernel Version (Typical) Node.js Version (Likely) Trigger Condition
CentOS 7.x 3.10.0-x.el7 (or similar < 4.9) 12.x, 14.x, 16.x Interacting with aggressive LBs/Firewalls
Ubuntu 16.04 LTS 4.4.0-x-generic (or similar < 4.9) 12.x, 14.x Interacting with aggressive LBs/Firewalls
Red Hat Enterprise Linux 7.x 3.10.0-x.el7 (or similar < 4.9) 12.x, 14.x, 16.x Interacting with aggressive LBs/Firewalls

Why Your Initial Debugging Failed (Probably):

  • Increasing timeouts: You probably tweaked agent.options.timeout or server.timeout. Useless. The connection isn't timing out; it's being abruptly severed without Node.js knowing it's dead until it tries to use it.
  • Updating Node.js: While generally good advice, Node.js itself isn't fundamentally broken here. It's reacting to the underlying OS/network behavior. Newer Node.js versions might be slightly more resilient, but they don't fix the core issue.
  • Checking netstat: You likely saw ESTABLISHED connections. That's the problem. From the client's perspective, they *are* established. From the rogue middlebox's perspective, they're dead weight.
  • Packet captures (tcpdump): You probably saw the client sending data, then getting an immediate RST or just nothing, causing the local kernel to eventually clean up and Node.js to raise hell. The real crime happened earlier, silently, by the intermediate device.

The Root Cause

Here's the ugly truth. You're running Node.js on a Linux kernel that, under certain circumstances, isn't as quick or robust at detecting remote peer closure without an explicit FIN or RST packet. Specifically, the kernel might not be signaling EPOLLRDHUP events efficiently for connections where the remote end (your upstream firewall, load balancer, or even the target server itself) has silently dropped its state for an idle TCP connection, without gracefully closing it.

Meanwhile, your http.Agent has keepAlive: true and a default keepAliveMsecs (often 60 seconds or more). This instructs Node.js to reuse idle sockets for subsequent requests. An intermediate network device (like an older F5 LTM, FortiGate, or even some cloud load balancers with aggressive idle timeouts) sees this connection sitting idle for its *own* configured timeout (e.g., 30-45 seconds) and simply drops its state. It sends no FIN. No RST. It just vanishes the connection from its table.

Node.js, oblivious, holds onto the socket. The Linux kernel, also oblivious (or slow to realize the other side is gone), keeps it in ESTABLISHED state. When Node.js finally tries to send data on this 'kept alive' socket, the middlebox intercepts it, realizes it has no state for this connection, and sends an immediate RST back. BAM! ECONNRESET or EPIPE. This kind of intermittent network failure isn't just annoying; it directly impacts execution latency, turning a seemingly robust application into a ticking time bomb, especially when you're dealing with demanding enterprise-grade workloads.

Fragmented digital network diagram with a glowing
Visual representation

The Fix: Starve the Beast, Force Closure.

The solution is to force Node.js to close and reopen connections *before* the rogue middlebox has a chance to prune them. You do this by setting a socketTimeout on your http.Agent that is shorter than the most aggressive idle timeout of any network device or server in your connection path. This forces the Node.js agent to destroy and recreate the socket, ensuring you're always using a fresh connection that the middlebox still acknowledges.

This is critical for enterprise applications. While Node.js is powerful for modern architectures, especially when weighing options like Spring Boot vs. NestJS, these low-level network quirks can derail even the best-designed systems. Don't be caught off guard.

Your Copy-Pasteable Solution:

Modify your http.Agent instantiation. If you're using axios or node-fetch, you'll need to pass this agent explicitly.


const http = require('http');
const https = require('https');

// Determine the most aggressive idle timeout in your path.
// Common values: 30s, 45s, 60s. Pick something comfortably below that.
const AGGRESSIVE_NETWORK_IDLE_TIMEOUT_MS = 40 * 1000; // e.g., 40 seconds

const agentOptions = {
    keepAlive: true,
    keepAliveMsecs: AGGRESSIVE_NETWORK_IDLE_TIMEOUT_MS, // This sets how long sockets are kept alive
    // CRITICAL: This ensures sockets are proactively destroyed if they're idle for too long,
    // preventing reuse of half-dead connections.
    socketTimeout: AGGRESSIVE_NETWORK_IDLE_TIMEOUT_MS / 2 // Or even less, e.g., 20 seconds.
                                                       // Must be LESS than external idle timeout.
};

const httpAgent = new http.Agent(agentOptions);
const httpsAgent = new https.Agent(agentOptions);

// Example usage with built-in http.request:
const reqOptions = {
    hostname: 'your-target-service.com',
    port: 80,
    path: '/api/data',
    method: 'GET',
    agent: httpAgent // Use your custom agent
};

const req = http.request(reqOptions, (res) => {
    // ... handle response
});

req.on('error', (e) => {
    console.error(`Problem with request: ${e.message}`);
    // You might still see ECONNRESET for other reasons, but not this specific ghost.
});

req.end();

// If using Axios:
// const axios = require('axios');
// const instance = axios.create({
//     httpAgent: httpAgent,
//     httpsAgent: httpsAgent
// });
// instance.get('http://your-target-service.com/api/data')
//    .then(response => { /* ... */ })
//    .catch(error => { /* ... */ });

Final Thoughts:

This isn't about blaming Node.js or Linux. It's about understanding the subtle, often undocumented, interactions between layers of a complex system. The deeper you go, the weirder it gets. Next time someone tells you networking is simple, just show them this. Get this deployed, monitor your error rates, and finally get some sleep.

Discussion

Comments

Read Next