Article View

Scroll down to read the full article.

The Ghost in the Machine: Node.js ECONNRESET on Outbound POSTs (Kernel 5.10+ & Node < 16.x)

calendar_month July 13, 2026 |
Quick Summary: Troubleshoot Node.js ECONNRESET on outbound HTTP POSTs with Linux kernel 5.10+ and Node.js versions below 16, caused by stale http.Agent connectio...

Alright, listen up. If you're an SRE, you've seen it all. But some problems hit different. Some are so specific, so subtle, they make you want to throw your monitor out the window. This is one of those. We're talking intermittent ECONNRESET errors on outbound HTTP POST requests from your Node.js services. Not on GETs, not consistently, just... occasionally. And only for POSTs. Maddening.

You've checked the network. You've tcpdump'd. You've sworn at the upstream team. You've blamed Kubernetes. Maybe you even blamed the phase of the moon. Stop. This isn't that. This is a confluence of an older Node.js runtime, a modern Linux kernel, and a specific NGINX configuration. It's a ghost in the machine that only appears under certain load profiles and disappears when you look too closely.

The Problem: Intermittent ECONNRESET on Node.js Outbound POSTs

Your Node.js microservice, acting as an HTTP client, is trying to send data to an upstream service (often behind NGINX). Most requests go fine. Then, seemingly at random, a POST request fails with ECONNRESET. The connection was established, the request started, and then BOOM – a reset during the write phase. Not a connection refusal, not a timeout on connect, but mid-flight. It smells like a stale connection, but your Node.js http.Agent is supposed to handle keep-alive properly, right?

Wrong. Or rather, mostly right, but there's a critical interaction nobody talks about. This isn't about network congestion or a flaky firewall. This is about TCP state, kernel behavior, and how Node.js's default connection pooling sometimes falls out of sync with reality.

The Specific Environment Trap

This particular beast thrives in a very specific habitat. It's a combination of client-side operating system, Node.js runtime, and upstream server configuration. You need all three for the perfect storm:

Component Version Range (Where Issue Triggers) Specific Condition / Default Behavior
Client OS Kernel Linux Kernel 5.10 - 5.15 (and potentially newer without specific tuning) Aggressive idle TCP connection handling, faster detection of unacked RSTs.
Node.js Runtime Node.js 12.x - 15.x Default http.Agent freeSocketTimeout: 0 (indefinite socket pooling) and less robust stale connection detection logic compared to Node.js 16+.
Upstream NGINX Server NGINX 1.18+ keepalive_timeout (e.g., 60s) for client connections and proxy_read_timeout (e.g., 30s) for upstream connections, often with proxy_buffering off;

A rusty
Visual representation

The Root Cause

Here's the ugly truth. The root cause is a nasty race condition and a misunderstanding between Node.js's http.Agent connection pool and the actual state of the TCP socket as perceived by the Linux kernel, exacerbated by NGINX's handling of client keep-alives and upstream timeouts.

When Node.js uses its default http.Agent with keepAlive: true, it pools connections. The key here is freeSocketTimeout, which defaults to 0 in Node.js versions prior to 16.x. This means a socket, once free, stays in the pool indefinitely until either the upstream server explicitly closes it, or a new request attempts to use it after a long idle period. This is where the problem starts. For a deeper dive into these pitfalls, you might want to read Node.js http.Agent Keep-Alive: The Stale Connection Time Bomb.

Concurrently, your NGINX upstream server has a keepalive_timeout, let's say 60 seconds. If a Node.js client connection goes idle for more than 60 seconds, NGINX will gracefully close its end of the connection. Node.js, on its end, still thinks the connection is valid because its freeSocketTimeout is 0 and it hasn't received a FIN packet yet. The Linux kernel on the Node.js client machine, especially newer versions (5.10+), is more aggressive in cleaning up idle or half-closed TCP connections or in detecting a `RST` from the remote end.

When Node.js eventually picks this stale socket from its pool for a new POST request, it attempts to write. The Linux kernel, upon seeing an attempt to write to a socket that has already received an NGINX-initiated close (or an implicit RST due to a timeout on NGINX's side), immediately responds with an ECONNRESET error. For GET requests, the payload is often small or non-existent, so the write happens quickly or less frequently, making the issue harder to reproduce. POSTs, with their potentially larger bodies, extend the write phase, increasing the likelihood of hitting this race condition.

The Fix: Taming the Agent's Timeout

The solution is to force Node.js's http.Agent to be more proactive about ditching idle sockets. We need to set a freeSocketTimeout that is less than NGINX's keepalive_timeout, providing a buffer. This ensures Node.js closes the socket on its end before NGINX has a chance to, preventing the stale connection scenario.

We've implemented this in high-throughput environments where even microsecond latency can be critical, ensuring predictable connection behavior is paramount. If you're dealing with similar performance demands, consider strategies outlined in Microsecond Wars: Engineering Ultra-Low Latency in Algorithmic Trading Systems.

A digital clock with seconds ticking down rapidly
Visual representation

Step-by-Step Solution

  1. Verify Symptoms: Confirm ECONNRESET specifically on outbound HTTP POST requests from Node.js applications running on Linux kernel 5.10+ using Node.js versions 12.x-15.x. Check your NGINX logs for corresponding client connection closures.
  2. Identify NGINX keepalive_timeout: Find your NGINX upstream's keepalive_timeout directive. Let's assume it's 60 seconds (60s).
  3. Configure Node.js http.Agent: Create a custom http.Agent instance and set freeSocketTimeout to a value comfortably less than your NGINX keepalive_timeout. A good rule of thumb is 80-90% of the NGINX timeout. For 60s, 50s (50000ms) is a safe bet.
  4. Apply the Override: Ensure all your HTTP POST requests (or all requests to the affected upstream) use this custom agent.
  5. Monitor: Deploy the change and monitor your error rates. The ECONNRESET errors should vanish for this specific pattern.

Code Override Example (for Node.js)

Here’s how you define and use a custom http.Agent:


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

// Create a custom agent for HTTP requests
const httpAgent = new http.Agent({
    keepAlive: true,
    maxSockets: 100, // Adjust based on your load
    freeSocketTimeout: 50000 // IMPORTANT: 50 seconds, less than NGINX's 60s keepalive_timeout
});

// Create a custom agent for HTTPS requests (if applicable)
const httpsAgent = new https.Agent({
    keepAlive: true,
    maxSockets: 100,
    freeSocketTimeout: 50000 // IMPORTANT
});

function makeRequest(url, method, payload) {
    const client = url.startsWith('https') ? https : http;
    const agent = url.startsWith('https') ? httpsAgent : httpAgent;

    return new Promise((resolve, reject) => {
        const options = {
            method: method,
            agent: agent, // Use the custom agent
            headers: {
                'Content-Type': 'application/json'
            }
        };

        if (payload) {
            options.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(payload));
        }

        const req = client.request(url, options, (res) => {
            let data = '';
            res.on('data', (chunk) => { data += chunk; });
            res.on('end', () => {
                if (res.statusCode >= 200 && res.statusCode < 300) {
                    try {
                        resolve(JSON.parse(data));
                    } catch (e) {
                        resolve(data); // In case of non-JSON response
                    }
                } else {
                    reject(new Error(`Request failed with status ${res.statusCode}: ${data}`));
                }
            });
        });

        req.on('error', (e) => {
            console.error(`Request failed: ${e.message}`);
            reject(e);
        });

        if (payload) {
            req.write(JSON.stringify(payload));
        }

        req.end();
    });
}

// Example usage:
// makeRequest('http://your-upstream-service/api/data', 'POST', { key: 'value' })
//     .then(response => console.log('Success:', response))
//     .catch(error => console.error('Error:', error.message));

Final Thoughts

This obscure issue highlights why SREs can't just be application specialists. You need to understand the entire stack, from kernel TCP behavior to application runtime defaults, and how they interact with your infrastructure components like NGINX. Don't assume defaults are always safe, especially as underlying systems evolve. Dig in, question everything, and force your application to respect the network's reality.

Read Next