What it is, who should use it, and how it works
What is it?
Upstream keepalive is Nginx's connection pooling for backend servers. When you add the keepalive directive to an upstream block, each Nginx worker process caches idle HTTP/1.1 connections to the backends and reuses them for later proxied requests. Without it, Nginx opens a new connection for each request. Turning it on safely also means aligning three sets of timeouts: Nginx's client-facing idle timeout, Nginx's upstream idle timeout, and the Node.js http.Server timeouts. This article covers that configuration and ordering, how to diagnose the 502s that appear when the ordering is wrong, and how to verify the fix.
Who should use it?
- Teams running a Node.js HTTP server behind Nginx as a reverse proxy, especially at moderate or high request rates. At those rates, per-request TCP handshakes and
TIME_WAITbuildup start to cost CPU, latency and ephemeral ports. - Services with bursty traffic. Quiet periods followed by spikes are the conditions that expose the idle-timeout race.
- Anyone seeing intermittent 502s with "upstream prematurely closed connection" or "Connection reset by peer" in the Nginx error log, particularly on the first requests after a lull.
The benefit is smaller for low-traffic services, where a handshake per request costs little. It also needs extra care in multi-hop chains, where every hop's timeouts must be ordered. The section on when to avoid or limit these changes covers those cases.
How does it work?
- A client request arrives at an Nginx worker. The worker picks a backend server from the
upstreamblock using the configured balancing method. - The worker checks its own cache for an idle connection to that server. If it finds one, it sends the request on that socket. If not, it opens a new TCP connection.
- The request goes out as HTTP/1.1 with the
Connectionheader cleared, so neither side asks to close the connection after the response. - When the response finishes and the connection is still reusable, the worker returns it to its idle cache. If the cache already holds
keepaliveidle connections, the least recently used one is closed. - An idle cached connection is closed when the upstream
keepalive_timeoutexpires. It is also closed when it has served the number of requests set by the upstreamkeepalive_requestsdirective, or when the backend closes it. - On the Node side,
http.Servertracks idle sockets and closes any socket that has had no new request withinserver.keepAliveTimeout.
The weak point is step 2. Nginx removes a cached connection when it sees the backend's close. If Node's close is still in flight when Nginx picks that socket, the request is written onto a connection that is already going away. The fix is to order the timeouts so that Nginx always retires an idle socket before Node's timer can fire. The rest of this article explains how.
What upstream keepalive is and why idle timeouts cause 502s
By default, Nginx proxies each request over a fresh HTTP/1.0 connection to the upstream and sends Connection: close, so the TCP connection is torn down after every response. Under load, that means a TCP handshake per request, sockets piling up in TIME_WAIT, and possible ephemeral port exhaustion on the proxy host. The keepalive directive in an upstream block changes this. Each worker process keeps a pool of idle connections to the backend and reuses them for later requests. This only works with proxy_http_version 1.1 and a cleared Connection header.
Reuse introduces a race. Nginx keeps an idle socket in its pool and assumes it is still usable. If Node's idle timer fires first, Node closes the socket. Nginx may then write a request onto that socket just as the close arrives. The request fails, Nginx logs something like "upstream prematurely closed connection", and the client gets a 502.
The three timeout layers
- Client
keepalive_timeout(in anhttporserverblock) controls how long Nginx holds idle connections from browsers and load balancers. It does not affect upstream sockets. - Upstream idle timing (
keepalive_timeoutinside theupstreamblock) controls how long Nginx keeps a pooled backend connection open before closing it. - Node's
server.keepAliveTimeoutis how long Node waits on an idle socket before closing it.headersTimeoutlimits how long Node waits for the request headers to arrive.requestTimeoutlimits how long it waits for the whole request. Node's idle default is only a few seconds, which is shorter than Nginx's.
The core rule is that Node's keepAliveTimeout must be longer than Nginx's upstream idle timeout. That way Nginx always closes an idle socket before Node does, and Nginx never sends a request onto a connection Node has already closed.
Teams running Node behind Nginx with steady or bursty traffic benefit most. Bursty traffic is especially exposed: a quiet gap lets many pooled sockets age past Node's timeout, and the next burst hits them all at once.
+-----------+ +---------------------------+ +------------------+
| Client |----->| Nginx worker process |----->| Node.js server |
| (browser/ |<-----| proxy_http_version 1.1 |<-----| (http.Server) |
| LB) | | proxy_set_header | | |
+-----------+ | Connection "" | +------------------+
| +---------------------+ |
| | upstream idle pool | |
| | [conn] [conn] [conn]| |
| +---------------------+ |
+---------------------------+
[1] Client side [2] Upstream idle [3] Node timeouts
keepalive_timeout keepalive_timeout server.keepAliveTimeout
(http/server blk) (inside upstream {}) headersTimeout
requestTimeout
Race if [3] Node idle timeout <= [2] Nginx upstream idle timeout:
Nginx --- socket idle in pool -------- reuse --> send request --X 502
Node --- socket idle --- timer fires --> close socket --------^
Rule: [3] Node keepAliveTimeout > [2] Nginx upstream keepalive_timeout
=> Nginx closes idle sockets first; no request lands on a closed one.
Nginx keeps a per-worker pool of idle HTTP/1.1 connections to Node. When Node's keepAliveTimeout is not longer than Nginx's upstream idle timeout, Node can close a pooled socket just as Nginx reuses it, and the client gets a 502.
Configuring Nginx and Node so Nginx always retires idle sockets first
Start in the upstream block. The keepalive directive turns on connection caching to the backend. Nginx only reuses those sockets if the proxied requests are HTTP/1.1 without a Connection: close header. Set proxy_http_version 1.1 and proxy_set_header Connection "" in the location that proxies to Node. If you skip either one, Nginx accepts the keepalive setting but still opens a new connection per request.
Next, set keepalive_timeout inside the upstream block. This is separate from the client-facing keepalive_timeout in the http or server context. The upstream version controls how long Nginx holds an idle backend socket. Keep it shorter than Node's server.keepAliveTimeout, with a margin of a few seconds. The side that times out first closes the socket. You want that to be Nginx, because Nginx won't send a request on a socket it has already retired.
On the Node side:
- Raise
keepAliveTimeoutabove the Nginx value. - Set
headersTimeoutabovekeepAliveTimeout. This stops a reused connection from hitting the header timer while Node still considers it alive.
keepalive is not a connection limit. It is the maximum number of idle connections each worker process caches for that upstream. Nginx opens more connections under load and closes the extras when they go idle. The total idle pool can reach worker_processes × keepalive, spread across your Node instances. For example, eight workers with keepalive 32 can hold 256 idle sockets. Check that your Node processes and file descriptor limits can handle that number.
Defaults have changed between releases. Check the documentation for your installed Nginx and Node versions before you rely on any value, including the ones in this article.
upstream node_app {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
# Per-worker cap on IDLE cached connections, not a total limit.
# Nginx opens more under load and closes the extras once idle.
keepalive 32;
# How long Nginx keeps an idle backend socket.
# Keep this a few seconds BELOW Node's server.keepAliveTimeout
# so Nginx always retires the socket first. Example Node values:
# server.keepAliveTimeout = 65000; // ms
# server.headersTimeout = 66000; // ms, above keepAliveTimeout
keepalive_timeout 60s;
}
server {
listen 80;
server_name app.example.internal;
location / {
proxy_pass
# Both are required for upstream reuse. Without them Nginx
# accepts 'keepalive' but opens a new connection per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
}
Nginx upstream keepalive config with an upstream keepalive_timeout set below the Node keepAliveTimeout and headersTimeout values shown in the comments, so Nginx closes idle sockets before Node does.
Which proxy timeout controls which failure
Nginx has three upstream timers, and each one guards a different phase of the proxied exchange. None of them caps the total duration of a request. proxy_read_timeout and proxy_send_timeout measure the gap between two successive read or write operations. A response that streams one byte every few seconds can therefore run indefinitely without tripping proxy_read_timeout.
| Directive | What it measures | Typical failure | Error log phrase |
|---|---|---|---|
proxy_connect_timeout |
Establishing the TCP connection to Node | 504 when the backend is overloaded or its accept queue is full | "while connecting to upstream" |
proxy_send_timeout |
Gap between successive writes of the request body to Node | 504 on large uploads to a stalled backend | "while sending request to upstream" |
proxy_read_timeout |
Gap between successive reads from Node | 504 on slow handlers that send nothing until they finish | "while reading response header from upstream" |
keepalive_timeout (http/server) |
How long an idle client connection stays open | None on the upstream side; affects clients only | — |
Note that keepalive_timeout in the http or server context faces the client. It has no effect on Nginx-to-Node sockets. The idle lifetime of upstream connections is set by the separate keepalive_timeout inside the upstream block, which the previous section aligned with Node's own timeout.
Raising proxy_read_timeout globally to accommodate one slow endpoint hides stalled requests everywhere else and keeps worker connections occupied. A narrower approach is to override it per location:
- Long-polling endpoints need a read timeout slightly longer than the server's maximum hold time.
- SSE endpoints need a read timeout longer than the heartbeat interval, plus
proxy_buffering offso events are not held back. - Report endpoints need a generous read timeout. A better fix is to move that work to a background job.
upstream node_app {
server 127.0.0.1:3000;
keepalive 32;
keepalive_timeout 55s; # upstream idle sockets, shorter than Node's keepAliveTimeout
}
server {
listen 80;
keepalive_timeout 65s; # client-facing idle timeout only
proxy_http_version 1.1;
proxy_set_header Connection ""; # allow upstream connection reuse
proxy_connect_timeout 5s; # fail fast if Node can't accept
proxy_send_timeout 30s; # max gap between writes to Node
proxy_read_timeout 30s; # max gap between reads; default for normal API calls
location / {
proxy_pass
}
location /poll/ {
proxy_pass
proxy_read_timeout 35s; # server holds polls up to 30s; allow a small margin
}
location /events/ {
proxy_pass
proxy_buffering off; # deliver SSE frames immediately
proxy_read_timeout 60s; # comfortably above the 15s heartbeat interval
}
location /reports/ {
proxy_pass
proxy_read_timeout 300s; # handler sends nothing until the report is built
}
}
Conservative server-wide upstream timeouts with per-location proxy_read_timeout overrides for long-polling, SSE and slow report endpoints, alongside the separate client-facing and upstream keepalive_timeout settings.
Diagnosing 502s from the Nginx error log
Every 502 or 504 that Nginx returns leaves a line in the error log, and the wording of that line tells you which side gave up. Search for the message text rather than the status code, because the same 502 can come from very different causes.
| Error log message | Typical status | Likely cause |
|---|---|---|
connect() failed (111: Connection refused) |
502 | Node is not listening: process down, still starting, or wrong port in upstream |
upstream timed out (110: Connection timed out) while reading response header |
504 | Handler slower than proxy_read_timeout, or event loop blocked |
upstream prematurely closed connection |
502 | Node closed a reused idle socket (keepalive race), or the process exited mid-request |
recv() failed (104: Connection reset by peer) |
502 | Same as above, but the socket was reset rather than closed cleanly |
The message alone does not settle the cause. You also need to know what was happening around it. Line up the error timestamps against these events:
- Process restarts. Check your supervisor or orchestrator logs for restarts at those timestamps. A cluster of "connection refused" errors that lines up with a restart points to a crash, not a misconfiguration.
- Deploys. Resets and premature closes that appear only during a rollout usually mean old workers are exiting without draining in-flight requests.
- Event-loop stalls. Timeouts that coincide with CPU spikes or garbage-collection pauses suggest a blocked loop, not a slow downstream dependency.
If none of these events line up, look at traffic timing. Premature closes that land on the first request after a quiet period are the signature of the keepalive race. Those errors appear regardless of load, which rules out capacity as the cause.
#!/usr/bin/env bash
set -euo pipefail
LOG="${1:-/var/log/nginx/error.log}" # Nginx error log to scan
UNIT="${2:-node-api.service}" # systemd unit running Node
SINCE="${3:-2 hours ago}" # window for restart events
echo "== Upstream errors per minute (count, date, HH:MM, class) =="
awk '
{ c = "" }
/\(111: Connection refused\)/ { c = "refused" } # Node not listening
/upstream timed out/ { c = "timeout" } # slow handler / blocked loop
/upstream prematurely closed/ { c = "premature_close" } # keepalive race or exit
/\(104: Connection reset by peer\)/ { c = "reset" } # same, but reset
c != "" { print $1, substr($2, 1, 5), c }
' "$LOG" | sort | uniq -c
echo
echo "== Start/stop events for $UNIT since '$SINCE' =="
journalctl -u "$UNIT" --since "$SINCE" -o short-iso --no-pager \
| grep -Ei 'start|stop|exit|kill' || echo "(no start/stop events found)"
A shell script that counts Nginx upstream errors by minute and by message type, then prints the Node service's systemd start and stop events for the same window so you can match error bursts to restarts and deploys.
Verifying the fix: connection reuse and idle-then-burst testing
Confirming connections are reused
Under steady traffic, the set of established connections from Nginx to the Node port should stay roughly constant. On the Nginx host, run ss -tn state established '( dport = :3000 )' (substitute your Node port) a few times, several seconds apart. With reuse working, the same local ephemeral ports appear in each sample, and the count stays near your keepalive pool size per worker. If the source ports change on every sample, Nginx is opening a new connection per request. That usually means proxy_http_version 1.1 or the cleared Connection header is missing. A rising count from ss -tan state time-wait '( dport = :3000 )' points to the same problem. On hosts without ss, netstat -tn filtered on the port shows the same information.
Idle-then-burst testing
Steady load cannot reproduce this bug, because pooled sockets never sit idle long enough for Node to close them. The test needs a pause instead:
- Send a short warm-up burst to fill the pool.
- Stay silent for longer than Node's
keepAliveTimeout. If it is 5 seconds, wait 8 to 10 seconds. - Burst again.
- Repeat the cycle dozens of times. The race depends on timing, so a single clean pass proves little.
Use POST requests for the burst. Nginx does not retry non-idempotent requests on the next upstream by default, so the race cannot be hidden by a successful retry.
Before the fix, expect occasional 502s on the first requests after each pause, along with matching "upstream prematurely closed" entries in the error log. After the fix, both should be absent across every cycle.
When to avoid or limit these changes, and the alternatives
Keepalive pools are not free. Each idle upstream connection holds a file descriptor and some memory on the Node process. If the keepalive count is multiplied across many Nginx workers and instances, a small backend can end up holding thousands of mostly idle sockets. Size the pool to steady-state concurrency, not peak.
A long keepAliveTimeout in Node also affects every client that connects directly, not only Nginx. Health checkers and load balancers that bypass Nginx will keep their sockets open just as long.
The ordering rule must hold at every hop. In a chain of cloud load balancer → Nginx → sidecar proxy → Node, each caller's idle timeout must be shorter than the next hop's. If one link is misordered, the 502s come back.
Deploys are a separate problem. A cluster or PM2 restart that kills workers abruptly drops in-flight and pooled connections, whatever the timeouts are set to.
Alternatives
| Option | What it fixes | Cost / risk |
|---|---|---|
Raise proxy_read_timeout globally |
Slow responses being cut off | Does nothing for idle-socket races; ties up connections on genuinely hung requests |
proxy_next_upstream retries |
Transparent recovery from a dead socket | Non-idempotent methods like POST are not retried by default; enabling non_idempotent risks duplicate writes |
| Disable upstream keepalive | Eliminates the race entirely | Extra TCP (and possibly TLS) handshake per request; more latency and CPU |
| Graceful shutdown in Node | 502s during deploys and restarts | Requires stopping accepts, draining, and closing idle sockets before exit |
Graceful shutdown complements the timeout ordering and does not replace it. Most production setups need both.
Impact on cost and engineering time
The larger savings are usually in engineering time, not compute. A timeout race produces 502s that appear only after idle periods, clear up under steady load, and rarely show up in staging. Teams can lose days to these before anyone checks the timeout ordering. When the ordering is correct and written down, that whole category of incident is ruled out.
Illustrative estimate (assumed numbers, not a benchmark)
Assume 2,000 upstream requests/s, a 95% connection reuse ratio once keepalive works, a 60 s TIME_WAIT period, and 0.5 ms network RTT.
- Handshakes avoided per second = request rate × reuse ratio = 2,000 × 0.95 = 1,900
- Sockets in TIME_WAIT = new connections/s × TIME_WAIT seconds
- Without reuse: 2,000 × 60 = 120,000, which is more than a typical ephemeral port range of tens of thousands for one destination.
- With reuse: 100 × 60 = 6,000.
- Latency removed per reused request ≈ 1 RTT ≈ 0.5 ms. If TLS runs to the upstream, it removes more.
The cost of masking the problem
Raising every timeout or adding blanket retries hides the symptom. Fast 502s become slow 504s. Retries on non-idempotent requests can turn one failed POST into a duplicate charge or a duplicate order. Those incidents cost more to clean up than the original 502s did.
Checklist for every new hop (load balancer, ingress, sidecar, proxy)
- Record each hop's idle timeout in one shared document.
- Confirm that each client-side idle timeout is shorter than the server-side timeout of the hop behind it.
- Allow retries only for idempotent methods.
- Run the idle-then-burst test before release.
- Require reviewers to check the timeout doc when proxy config changes.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nginx upstream keepalive, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.