The Mystery That Drove Me Crazy for Two Days
Picture this: your Node.js application is running perfectly. Users are happy, response times are fast, everything looks clean on the dashboard. Then, almost like clockwork, something weird happens. Requests start hanging. The app slows to a crawl. You check your logs and there's nothing obviously wrong. You restart the server and everything snaps back to normal — for a while. Then it happens again.
I've been there. I spent nearly two days chasing what I thought was a memory leak, then a database connection issue, then a bug in my async code. I added logging everywhere. I profiled the heap. I stared at flame graphs until my eyes crossed. The answer, when I finally found it, was embarrassingly simple — and it had nothing to do with any of those things.
The culprit was a keep-alive timeout mismatch between my Node.js server and the load balancer sitting in front of it. And the kicker? This exact problem silently kills Node.js applications running behind AWS ALB, NGINX, or any reverse proxy, and most developers have no idea it's even a category of problem that can exist.
What "Keep-Alive" Actually Means
Before we get into the mismatch, let's talk about what HTTP keep-alive actually is, because it's one of those terms people throw around without fully explaining.
When your browser (or any HTTP client) makes a request to your server, it has to open a TCP connection first. TCP connections — the underlying "pipe" that HTTP data flows through — are not free. They take time to establish through a process called the three-way handshake (client says "hello," server says "hello back," client confirms). For a single request, this overhead is small. But if your app is handling thousands of requests per second, opening a fresh TCP connection for every single one is brutally expensive.
HTTP keep-alive solves this by telling both sides: hey, let's keep this connection open after the request finishes, so the next request can reuse it. This is sometimes called HTTP persistent connections. Think of it like keeping a phone line open during a conversation instead of hanging up and redialing every single time you want to say something new. Modern HTTP/1.1 has keep-alive enabled by default, and it's one of the foundational performance features of the web.
The important part — and this is where the trouble starts — is that both the client and the server have a timeout for how long they'll keep that idle connection open. The server says "I'll wait up to X seconds for another request before I close this connection." The client says "I'll wait up to Y seconds before I give up and close it on my end." If those two numbers are misaligned, you get a very specific kind of chaos.
The Mismatch: When Your Load Balancer Disagrees With Node.js
Here's the scenario that causes the problem. You deploy your Node.js app behind a load balancer — let's say AWS Application Load Balancer (ALB), which is incredibly common. AWS ALB has a default idle timeout of 60 seconds. That means the load balancer will hold a keep-alive connection open for 60 seconds of inactivity before closing it.
Node.js, on the other hand, has a keepAliveTimeout default that changed over the years. In older versions of Node.js (before v18.0.0, specifically), the default keepAliveTimeout was set to just 5 seconds. In more recent versions, it was bumped to 5 seconds and then various values depending on your version. But here's the real-world smoking gun: many production systems have their Node.js keepAliveTimeout set to something like 5 seconds, while the load balancer in front of them keeps connections alive for 60 seconds.
So what happens? The load balancer thinks a connection is still perfectly valid and usable. It has a pending request that needs to go somewhere, and it picks that "open" connection to Node.js — a connection that Node.js has already quietly closed on its end. Node.js sends back a TCP RST (reset) packet, which is essentially a hard "this connection no longer exists." The load balancer gets this unexpected closure after it already sent the request data. Depending on the load balancer's implementation, it may retry, but it may also return a 502 Bad Gateway error to the user. At high traffic volumes, this produces the exact pattern I described: seemingly random errors and slowdowns that are frustrating to diagnose precisely because the Node.js app itself is technically healthy. The problem lives in the gap between two components.
Why It Appears After "Exactly 30 Seconds"
You'll see different time intervals depending on your specific configuration, but 30 seconds is a very common one. Some teams set their Node.js keepAliveTimeout to 30 seconds thinking they're being generous. NGINX, another extremely common reverse proxy, has its own upstream keepalive timeout that might be set higher. The result is the same: after exactly 30 seconds of a connection sitting idle, Node.js closes it. The proxy doesn't know this yet. The next request that arrives gets routed over a dead connection.
I've found this is especially noticeable in applications with bursty traffic patterns. Think of a reporting dashboard that gets hammered with requests every 30-60 seconds when users refresh. Or a microservice that sits mostly idle between scheduled jobs. In these cases, connections go idle long enough to hit the timeout, and then suddenly fail the moment new traffic arrives. Developers look at the timing, say "this happens every 30 seconds," and go down the rabbit hole of looking for scheduled tasks, cron jobs, or periodic database queries. The real answer is staring at them in the connection timeout setting.
How to Actually Fix It
The fix is straightforward once you understand the root cause. You need to make Node.js's keep-alive timeout longer than the load balancer's timeout. Give the load balancer a chance to close the connection on its end first, so Node.js never kills a connection the proxy still thinks is alive.
Here's how you set it in a basic Node.js HTTP server:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
// Set keepAliveTimeout HIGHER than your load balancer's idle timeout
// AWS ALB default is 60 seconds, so we set this to 65 seconds
server.keepAliveTimeout = 65000; // milliseconds
// This is equally important — headers timeout should be slightly longer
server.headersTimeout = 66000; // milliseconds
server.listen(3000, () => {
console.log('Server running on port 3000');
});The headersTimeout setting is one that catches people off guard. It defines how long Node.js will wait to receive complete HTTP headers on a connection. If it's set lower than keepAliveTimeout, you can end up with a situation where a connection is kept alive but headers never arrive in time, which causes the same class of problem. Set headersTimeout to a value slightly higher than keepAliveTimeout. A one-second buffer is usually enough.
If you're using Express.js, you don't get this for free from Express itself — you need to configure it on the underlying server instance that Express creates:
const express = require('express');
const app = express();
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
const server = app.listen(3000, () => {
console.log('Express app running on port 3000');
});
// Express wraps a standard http.Server — configure timeouts here
server.keepAliveTimeout = 65000;
server.headersTimeout = 66000;Notice that app.listen() returns the underlying http.Server instance. That's the object you need to configure. I've seen teams spend hours confused about why setting properties on the Express app object does nothing for server-level timeouts — it's because app is not the server, it's a request handler.
The NGINX Side of the Equation
If your reverse proxy is NGINX rather than AWS ALB, the configuration lives in a different place. NGINX has an upstream keepalive directive that controls how many keepalive connections it maintains to upstream servers (your Node.js app), and a keepalive_timeout that controls how long those connections stay open.
http {
upstream nodejs_backend {
server localhost:3000;
# Keep up to 32 idle connections to upstream
keepalive 32;
# Close upstream connections after 60 seconds idle
keepalive_timeout 60s;
}
server {
listen 80;
location / {
proxy_pass http://nodejs_backend;
proxy_http_version 1.1;
# These headers are essential for keepalive to work through the proxy
proxy_set_header Connection "";
proxy_set_header Host $host;
}
}
}In this NGINX config, the keepalive_timeout 60s on the upstream block means NGINX will hold connections to your Node.js backend for 60 seconds. So your Node.js server should have a keepAliveTimeout of at least 61 or 65 seconds. The proxy_set_header Connection "" line is critical — it strips out the Connection header from the forwarded request, which prevents HTTP/1.0 connection-closing behavior from leaking through. Without that line, NGINX might tell your Node.js server to close the connection after each request, defeating the entire point of keep-alive.
Diagnosing the Problem in Your Own App
If you're reading this and thinking "wait, I might have this problem," here's how to check. The most direct way is to look at your 502 error rate in your load balancer logs and correlate it with connection age. AWS ALB logs include a target_status_code field — a spike in 502s that isn't accompanied by application errors in your Node.js logs is a strong signal.
You can also check your current Node.js timeout configuration at runtime. If keepAliveTimeout outputs a value lower than your load balancer's idle timeout, you have the mismatch. Another thing I look for is the connection reset by peer error message in load balancer or proxy logs — that's the TCP RST from Node.js, and it's essentially a fingerprint of this specific problem.
The fix is one of those rare cases where adding two lines of configuration to your server setup eliminates an entire category of mysterious production failures. Once you understand the mental model — the proxy and the app both have timers, and the app's timer must always be the last to expire — it becomes a rule you carry with you forever. Every new Node.js service you deploy, you set those timeouts before it ever sees production traffic. It takes thirty seconds to configure, and it saves you hours of debugging later.