Two timeout bugs are common in Node APIs that sit behind a load balancer. The first is a trickle of 502s that never reproduce locally: a few an hour, no matching error in your app logs, more of them after quiet periods. The second is a slow handler that holds a socket open for minutes because nothing told it to stop.

Both come from four settings on http.Server that most apps never touch. This post covers what each one controls, which values fix the 502s behind an AWS Application Load Balancer, and how to check the fix worked.

The four settings on http.Server

From the Node.js HTTP docs:

Setting What it limits Default
requestTimeout Time to receive the entire request from the client 300 s (since Node 18)
headersTimeout Time to receive the complete request headers The lower of requestTimeout and 60 s
keepAliveTimeout Idle time on a kept-alive socket after the last response, before Node destroys it 5 s on older lines; 65 s after nodejs/node#62782
timeout Socket inactivity of any kind 0 (disabled, since Node 13)

requestTimeout and headersTimeout only cover receiving the request. Once your handler is running, neither one does anything. When headersTimeout expires, Node answers 408 and closes the connection without calling your handler.

keepAliveTimeout changed in 2026. The default was 5 seconds for years, and the Node change above raises it to 65 seconds. The docs still show both numbers in different places, so don't trust the default. Set it yourself and log it at startup:

console.log({ keepAliveTimeout: server.keepAliveTimeout, headersTimeout: server.headersTimeout, requestTimeout: server.requestTimeout, }); 

Node 24.6 and 22.19 added keepAliveTimeoutBuffer, default 1 second. The socket stays open for keepAliveTimeout + keepAliveTimeoutBuffer, which the docs say is there to reduce ECONNRESET errors from clients reusing a socket right as it closes. You rarely need to change it.

Bug one: Node closes the connection before the load balancer does

A load balancer keeps a pool of open connections to your app and reuses them. An AWS ALB keeps an idle connection for 60 seconds by default (configurable from 1 to 4,000).

With the old 5 second keepAliveTimeout, Node closes an idle socket after 5 seconds while the ALB still has 55 seconds left on it. If the ALB sends a request down that socket at the moment Node closes it, the request has nowhere to go and the ALB returns 502 to the client. Your app never saw the request, so there is no log line for it.

AWS says this in the same doc: set your application's idle timeout higher than the load balancer's, or the load balancer "sends an HTTP 502 Bad Gateway error to the client."

The fix:

  • keepAliveTimeout above the balancer's idle timeout. For a default ALB, 65 seconds.
  • headersTimeout a little above keepAliveTimeout, so the headers timer never fires first on a reused socket. 66 seconds works.

If you raised the ALB idle timeout (common for long uploads or streaming), raise keepAliveTimeout with it.

Other platforms vary. Render's web service docs don't publish an upstream idle timeout. Fly.io lets you set one in fly.toml with http_options.idle_timeout but doesn't state the default. On those, set keepAliveTimeout explicitly and watch your 502 count after you deploy.

Bug two: no deadline on slow handlers

Since requestTimeout stops counting once the request has arrived, a handler that waits on a slow database query or a third-party API can run for as long as that call takes. The client gives up, the ALB gives up, and your process keeps working on a response nobody will read.

Put the deadline in the handler. AbortSignal.timeout() gives you a signal that aborts after a fixed time, and fetch cancels the request when that signal aborts. So do database drivers that accept one:

app.get('/reports/:id', async (req, res, next) => { const signal = AbortSignal.timeout(10_000); try { const report = await buildReport(req.params.id, { signal }); res.json(report); } catch (err) { if (err.name === 'TimeoutError') { return res.status(504).json({ error: 'report_timeout', limitMs: 10_000 }); } next(err); } }); 

Use 504 when an upstream call ran out of time and 503 when your own work did. Either way, put a reason in the body. A bare 500 after ten seconds tells whoever is on call nothing.

The signal only helps if the code you call honors it. Check that your database client and HTTP client accept an AbortSignal and pass it all the way down.

Where to set them

In Express, app.listen() returns the http.Server, so set the properties on it:

const server = app.listen(3000); server.keepAliveTimeout = 65_000; server.headersTimeout = 66_000; server.requestTimeout = 120_000; 

In Fastify, pass them as factory options. Fastify's own defaults differ from Node's: keepAliveTimeout is 72 seconds, and requestTimeout and connectionTimeout are 0 (no limit). Fastify has no headersTimeout option, so set it on the underlying server:

const fastify = Fastify({ keepAliveTimeout: 65_000, requestTimeout: 120_000, }); fastify.server.headersTimeout = 66_000; 

Fastify's docs ask for a non-zero requestTimeout (they suggest 120 seconds) if the app isn't behind a reverse proxy.

Values to start from behind a default ALB:

Setting Value Why
keepAliveTimeout 65,000 ms Above the ALB's 60 s idle timeout
headersTimeout 66,000 ms Above keepAliveTimeout
requestTimeout 120,000 ms Long enough for real uploads, short enough to drop stalled ones
Per-route deadline 5 to 15 s Set per endpoint with AbortSignal.timeout()

Confirm the fix

The 502s from bug one come from the load balancer, so look for them there. CloudWatch publishes HTTPCode_ELB_502_Count for every ALB (metric list). Chart it by hour for a week before the change and a week after. Look at quiet hours most closely: that's where idle connections pile up.

Bug two shows up in your own app as 503 and 504 responses with a reason. In ApiTraffic, filter the request log by status code and turn on the Events switch to put your deploy marker on the status code chart, so you can see the timeouts per endpoint before and after the change.

See what's hitting your API: ApiTraffic