Keep-Alive & Connection Limits
Tune HTTP keep-alive and connection timeouts so Node servers behave correctly behind nginx, ALB, and other reverse proxies.
Recipe
Quick-reference recipe card - copy-paste ready.
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain", Connection: "keep-alive" });
res.end("ok\n");
});
// Align with your reverse proxy (nginx default keepalive_timeout is 75s)
server.keepAliveTimeout = 65_000; // slightly below proxy timeout
server.headersTimeout = 66_000; // must exceed keepAliveTimeout
server.requestTimeout = 30_000;
server.maxConnections = 1000; // cap concurrent sockets
server.listen(3000);When to reach for this: Intermittent 502 errors during deploys, connection reset logs, or EMFILE (too many open files) under load.
Working Example
import { createServer } from "node:http";
import { Agent, request as httpRequest } from "node:http";
// --- Server tuning ---
const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 30_000;
server.listen(3000);
// --- Outbound client with connection reuse ---
const agent = new Agent({
keepAlive: true,
maxSockets: 50, // per-host concurrent connections
maxFreeSockets: 10, // idle sockets kept in pool
timeout: 30_000,
});
export function callDownstream(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ hostname: "api.internal", port: 8080, path, agent, timeout: 10_000 },
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks).toString()));
}
);
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
req.end();
});
}What this demonstrates:
- Server-side keep-alive timeout aligned below the proxy
headersTimeoutgreater thankeepAliveTimeout(Node requirement)- Outbound
AgentwithkeepAlive: truefor connection pooling - Per-request timeout on outbound calls
Deep Dive
How It Works
- HTTP/1.1 defaults to persistent connections (keep-alive)
- After a response, the TCP socket stays open for reuse
- If server and proxy disagree on when to close, one side sends data on a closed socket (RST)
- Node's
keepAliveTimeoutcontrols how long an idle server socket waits for the next request
Proxy Alignment Table
| Layer | Setting | Typical value | Rule |
|---|---|---|---|
| nginx | keepalive_timeout | 75s | Proxy closes idle upstream |
| Node server | keepAliveTimeout | 65s | Must be less than proxy |
| Node server | headersTimeout | 66s | Must be greater than keepAliveTimeout |
| ALB | idle timeout | 60s default | Set Node keepAlive below ALB idle |
| Outbound Agent | maxSockets | 50-100 | Prevent one host from monopolizing FDs |
TypeScript Notes
// Log when connections approach limits
server.on("connection", (socket) => {
socket.setTimeout(30_000);
socket.on("timeout", () => socket.destroy());
});Gotchas
- 502 during rolling deploys - proxy sends request to a socket Node already closed. Fix: set
keepAliveTimeout5-10s below proxy timeout. headersTimeouttoo low - Node 18+ destroys connections mid-headers. Fix:headersTimeout > keepAliveTimeout.- No outbound Agent - every HTTP call opens a new TCP connection. Fix: reuse a module-level
AgentwithkeepAlive: true. maxSocketsdefault (Infinity) - one slow downstream can exhaust file descriptors. Fix: capmaxSocketsper host.- Ignoring
server.maxConnections- under attack or viral traffic, accept queue grows unbounded. Fix: setmaxConnectionsand handleserver.on("drop"). - ULIMIT too low - default 1024 FDs is not enough for busy APIs. Fix: raise
ulimit -nin container/systemd config.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| HTTP/2 at proxy | Multiplexing reduces connection count | You need Node to speak HTTP/2 directly |
| Connection pooling library (undici) | Modern fetch with built-in pooling | Already standardized on got or axios with agents |
| Unix domain sockets | nginx on same host as Node | Cross-host deployment |
| Service mesh (Envoy) | mTLS + retries + circuit breaking | Simple single-service deploy |
FAQs
What is the nginx keepalive_timeout default?
75 seconds. Set Node keepAliveTimeout to 65s or lower so Node closes first, avoiding race conditions with the proxy.
Should I disable keep-alive?
Almost never for APIs. Disabling increases latency and CPU from TCP handshakes. Only consider it for debugging specific connection issues.
How do I monitor open connections?
server.getConnections((err, count) => ...) for active count. Use ss -s or Prometheus node_netstat for system-wide view.
Does Fastify handle this automatically?
Fastify uses the underlying Node server. You still set keepAliveTimeout on fastify.server after listen().
What about WebSocket keep-alive?
WebSockets use ping/pong frames, not HTTP keep-alive. See ws Library.
How many maxConnections should I set?
Start with (expected RPS * avg response time) * 2. A 1000 RPS API with 50ms latency needs roughly 50-100 concurrent connections, but burst capacity needs headroom.
Does keep-alive affect load balancer health checks?
Health checks use separate short-lived connections by default. Misconfigured keep-alive mainly affects user traffic during deploys.
What is the difference between keepAliveTimeout and requestTimeout?
keepAliveTimeout applies to idle sockets between requests. requestTimeout limits how long a single active request can run.
Related
- Reverse Proxy Awareness - X-Forwarded headers and trust proxy
- HTTP/2 & HTTP/3 Considerations - protocol termination
- HTTP Basics in Node - server fundamentals
- Performance on Express - framework-level tuning
- HTTP Fundamentals Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.