Ch. 6 · Node.js

Node.js HTTP Timeouts and Resource Limits

Node's HTTP server and fetch timeout defaults, why headersTimeout fired after 30 seconds instead of 1, and how to set deadlines on both sides.

~6 min readintermediateupdated Oct 7, 2026

Every open connection costs a server something: a file descriptor, some memory, maybe a slot in a pool. A timeout is how you put a bound on that cost when the other side stops making progress. Without one, a slow or malicious client can hold resources indefinitely, and a slow downstream service can make your requests pile up until the process runs out of memory or file descriptors.

Node has timeouts on both sides of HTTP: the server you run, and the requests you make to other services. Their defaults are reasonable for “don’t fall over” and wrong for “behave predictably under failure”. In interviews this comes up as “what timeouts would you set on a Node service, and why?” and often as a debugging question about 502s behind a load balancer.

What the server does by default

Printing the relevant properties from a fresh http.createServer() on Node 22:

const server = http.createServer(handler);
const { headersTimeout, requestTimeout, keepAliveTimeout, timeout, maxHeadersCount, maxRequestsPerSocket } = server;
console.log({ headersTimeout, requestTimeout, keepAliveTimeout, timeout, maxHeadersCount, maxRequestsPerSocket });
JavaScript
{
  headersTimeout: 60000,
  requestTimeout: 300000,
  keepAliveTimeout: 5000,
  timeout: 0,
  maxHeadersCount: null,
  maxRequestsPerSocket: 0
}
Text

What each one bounds:

  • headersTimeout (60 s): how long a client may take to send the complete request headers. This is the defence against slowloris, an attack where a client opens many connections and sends headers one byte at a time, never finishing.
  • requestTimeout (300 s): how long the client may take to send the entire request, headers and body. Five minutes is generous for an API that accepts small JSON bodies.
  • keepAliveTimeout (5 s): how long an idle keep-alive connection stays open waiting for the next request.
  • timeout (0, disabled): an inactivity timeout on the socket, covering the case where your own handler is slow. Zero means Node will wait forever for your code to respond.
  • maxHeadersCount (null, which means the 2000 limit applies) and the 16 KiB header-size limit cap header parsing. maxRequestsPerSocket (0, unlimited) can force clients to reconnect periodically, which helps rebalance load.

Notice that nothing bounds how long your handler takes. If a handler awaits a downstream call that never returns, that request hangs until the client gives up. That’s your job, and it’s covered below.

A slow client and a surprising 30 seconds

Here’s a test. The server sets a 1-second header timeout. A raw TCP client sends a request line and then one header every 400 ms, never finishing:

import http from 'node:http';
import net from 'node:net';

const server = http.createServer({ headersTimeout: 1000, requestTimeout: 2000 }, handler);

server.listen(0, () => {
  const t0 = Date.now();
  const sock = net.connect(server.address().port, () => {
    sock.write('GET / HTTP/1.1\r\nHost: x\r\n');
    const drip = setInterval(() => sock.write(`X-Drip-${Date.now() - t0}: 1\r\n`), 400);
    sock.on('close', () => clearInterval(drip));
  });
  sock.on('data', (d) => console.log(`slow client after ${Date.now() - t0} ms got: ${d.toString().split('\r\n')[0]}`));
});
JavaScript

The first run printed:

slow client after 30027 ms got: HTTP/1.1 408 Request Timeout
Text

Thirty seconds, not one. The header and request timeouts aren’t individual timers per socket; a sweep checks all connections periodically, and its interval, connectionsCheckingInterval, defaults to 30 seconds. A deadline is only noticed at the next sweep. Setting the interval explicitly in the server options:

const server = http.createServer(
  { headersTimeout: 1000, requestTimeout: 2000, connectionsCheckingInterval: 250 },
  handler,
);
JavaScript
slow client after 1261 ms got: HTTP/1.1 408 Request Timeout
Text

Now the slow client is cut off with a 408 Request Timeout shortly after its deadline. For most services the defaults are fine as long as you understand that real enforcement can lag by up to 30 seconds. If you lower the timeouts for slowloris protection, lower the interval too. In practice a reverse proxy (nginx, Envoy, a cloud load balancer) usually buffers requests and absorbs slow clients before they reach Node, but the Node limits still matter for anything exposed directly.

Keep-alive behind a load balancer

This is the timeout bug that shows up in real incidents. A load balancer keeps a pool of keep-alive connections to your Node instances. Node closes idle connections after keepAliveTimeout, 5 seconds by default. An AWS Application Load Balancer keeps idle connections for 60 seconds by default.

So the load balancer considers a connection reusable for 60 seconds, while Node closes it after 5. If the load balancer sends a request on a connection at the same moment Node is closing it, the request hits a reset socket and the client gets a 502 Bad Gateway. It happens rarely, only at low to moderate traffic where connections sit idle for about 5 seconds, so it looks random and doesn’t reproduce in load tests.

The fix is to make the upstream’s idle timeout longer than the load balancer’s, so the load balancer always closes first:

server.keepAliveTimeout = 65_000;   // longer than the ALB's 60 s idle timeout
server.headersTimeout = 66_000;     // historically had to exceed keepAliveTimeout
JavaScript

The headersTimeout line comes from older Node versions, where headersTimeout also applied to the gap before the next request on a kept-alive connection; if it was shorter than keepAliveTimeout, the connection was cut early anyway. Setting it slightly higher is harmless on current versions and avoids surprises if the service runs on an older runtime.

Deadlines on outgoing requests

The client side is where most production hangs come from. Node’s built-in fetch (powered by undici) has no overall deadline. Undici’s own defaults wait up to 300 seconds for response headers and 300 seconds between body chunks, which is effectively “hang” from the point of view of a user waiting on your API. Give each call an explicit budget:

try {
  await fetch(`http://localhost:${port}/slow`, { signal: AbortSignal.timeout(500) });
} catch (err) {
  console.log(`fetch gave up after ${Date.now() - t1} ms: ${err.name}: ${err.message}`);
}
JavaScript

Against an endpoint that takes 3 seconds:

fetch gave up after 505 ms: TimeoutError: The operation was aborted due to timeout
Text

AbortSignal.timeout produces a TimeoutError, distinct from the AbortError you get from a manual controller.abort(). That distinction is useful: a timeout might deserve a retry, and a cancellation usually shouldn’t.

Two refinements interviewers like to hear:

Budgets, not fixed values. If your endpoint promises to respond within 2 seconds and makes three sequential downstream calls, each with a 2-second timeout, the worst case is 6 seconds. Compute a deadline when the request arrives and give each call the time remaining, minus a margin for your own work.

Cancel when the caller leaves. If the client disconnects, there’s no point finishing the downstream calls. Combine signals:

app.get('/quote', async (req, res) => {
  const clientGone = new AbortController();
  res.on('close', () => { if (!res.writableFinished) clientGone.abort(); });
  const signal = AbortSignal.any([clientGone.signal, AbortSignal.timeout(1500)]);
  const quote = await fetch(PRICING_URL, { signal }).then((r) => r.json());
  res.json(quote);
});
JavaScript

AbortSignal.any (Node 20.3 and later) aborts when any of its inputs does. The same signal can go to database drivers and other libraries that accept one.

Retries interact with all of this. Retrying a timed-out call without a budget multiplies the worst-case latency, and retrying on every instance at once multiplies load on a service that’s already slow. Retries need their own limit, backoff with jitter, and should only apply to idempotent operations.

Interview practice

A Node API sits behind an AWS ALB. Users report occasional 502 errors, about 0.1% of requests, mostly at night when traffic is low. The Node logs show no errors at all for those requests, and the service’s p99 latency is fine. What’s your hypothesis and fix?

Answer

The fact that Node logged nothing means the requests never reached a handler, so the 502 is generated by the ALB because the connection to the target failed. Low traffic is the clue: connections sit idle long enough to hit Node’s keepAliveTimeout of 5 seconds, while the ALB considers them reusable for its 60-second idle timeout. When the ALB picks a connection at the moment Node closes it, the request fails with a reset, and the ALB returns 502. At high traffic, connections are rarely idle for 5 seconds, so it mostly happens at night.

I’d confirm by checking the ALB’s target connection error metrics and access logs (502s with a target response time of -1 point to connection-level failures), then set server.keepAliveTimeout to something above the ALB’s idle timeout, such as 65 seconds, and server.headersTimeout slightly higher than that for older Node versions. If the ALB idle timeout has been changed from the default, match against the configured value instead.

Further reading

More in Node.js

read ✓Node.js · mid

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readread →
read ✓Node.js · mid

Node.js Response Compression with zlib

Stream gzip or Brotli responses with node:zlib, negotiate Accept-Encoding, send Vary, and skip tiny bodies and files already compressed.

~7 min readread →
esc