HTTP Keep-Alive: Persistent Connections Explained

HTTP keep alive lets one TCP (and TLS) connection carry many requests instead of one, so every reused request skips the handshake. On an 80 ms round trip, a fresh HTTPS connection over TLS 1.3 spends about 160 ms on handshakes before the first request byte leaves the client; a reused persistent connection spends zero. The hard part is not turning it on. It is the chain of idle timeouts between browser, CDN edge, load balancer and origin, and getting their order wrong is a classic cause of intermittent 502 errors.

As of 2026, a new HTTPS connection over TLS 1.3 costs two network round trips (one for TCP, one for TLS) before the first request can be sent, and three with TLS 1.2. HTTP keep-alive removes that cost for every request after the first on the same connection, which is why connection reuse ratio is one of the highest-leverage latency metrics on any CDN-to-origin path.

How a persistent connection saves time beyond the handshake

The handshake is the visible saving. The less visible one is congestion window state. A new TCP connection starts with an initial congestion window of 10 segments (about 14.6 KB), so a 500 KB response on a cold connection needs several round trips of slow start before it reaches link capacity. A warm persistent connection has already grown its window, and the next response can go out at close to full rate.

That advantage disappears quietly on Linux. With the default net.ipv4.tcp_slow_start_after_idle set to 1, the kernel resets the congestion window after the connection sits idle longer than one retransmission timeout. A keep-alive connection that idles for two seconds between requests keeps its handshake savings but restarts slow start. On CDN edges and origins that serve large objects over long-lived connections, setting that sysctl to 0 is one of the cheapest wins available.

Connection reuse also cuts server-side cost. Each new TLS connection runs a key exchange, allocates socket buffers and a file descriptor, and on close leaves a socket in TIME-WAIT on whichever side closed first. At low request rates none of this matters. At thousands of requests per second to a single origin, it decides whether the origin path is stable.

Worked calculation: what no reuse costs at the origin

Take a CDN tier sending 2,000 cache-miss requests per second to one origin IP and port, over a 100 ms round trip, using TLS 1.3. The numbers below are arithmetic, not a benchmark.

  • Without reuse: 2,000 handshakes per second, each adding about 200 ms (two round trips) to miss latency, so every miss pays at least 300 ms before the origin processing time counts.
  • TIME-WAIT pressure: Linux holds an actively closed socket in TIME-WAIT for 60 seconds. If the edge closes, 2,000 closes per second leaves about 120,000 sockets in TIME-WAIT toward that one destination.
  • Port exhaustion: the default ephemeral range of 32768 to 60999 gives 28,232 source ports per source IP toward one destination address and port. At 2,000 new connections per second, that range is gone in about 14 seconds without reuse or tcp_tw_reuse.
  • With a 95% reuse ratio: 100 new connections per second, 6,000 TIME-WAIT sockets, and 95% of misses pay one round trip instead of three.

The takeaway from the arithmetic: at origin-facing request rates, connection reuse is not a latency tweak, it is what keeps the source port table from running dry.

What does the Keep-Alive header actually do?

The Keep-Alive header is an advisory hint, not a contract. A server sending "Keep-Alive: timeout=5, max=100" tells the client it will probably close an idle connection after 5 seconds and after 100 requests. Clients may ignore it, and servers may close earlier. HTTP/1.1 connections are persistent by default, so the header is not required to get reuse.

The history explains the confusion. HTTP/1.0 had no persistent connections by default, so clients opted in with "Connection: keep-alive" and servers echoed it, sometimes with the Keep-Alive header attached. HTTP/1.1 (now RFC 9112) inverted the default: connections stay open unless either side sends "Connection: close". The Keep-Alive header was never fully standardized and survives mostly as an informational hint.

In HTTP/2 and HTTP/3, both headers are forbidden. RFC 9113 lists Connection and Keep-Alive as connection-specific fields, and a compliant endpoint treats a request carrying them as malformed. A proxy that blindly forwards a Keep-Alive header from an HTTP/1.1 origin into an HTTP/2 client response is a real source of protocol errors, so edges and proxies strip them on protocol translation. In HTTP/2, idle connections end with a GOAWAY frame rather than a header hint.

One more neighbor gets mixed up with all of this. TCP keepalive (the SO_KEEPALIVE socket option, with a Linux default of 7,200 seconds before the first probe) detects dead peers at the transport layer. It does nothing to make HTTP reuse connections, and HTTP keep-alive does nothing to probe for dead peers.

Tuning http keep alive timeouts across browser, CDN and origin

The rule for every hop is the same: the client side of a connection must give up on an idle connection before the server side does. If the server closes first, there is a window where the client picks the connection from its pool and sends a request at the same moment the server's FIN or RST is in flight. The client sees a reset, and a proxy turns that into a 502 or a retry.

The textbook case is a Node.js service behind a load balancer. Node's HTTP server defaults keepAliveTimeout to 5 seconds, while an AWS Application Load Balancer defaults to a 60 second idle timeout and reuses backend connections. Every few thousand requests the balancer reuses a connection Node just closed, and the result is a trickle of 502s that never shows up in Node's own logs.

Component Default idle timeout Default max requests per connection Role in the chain
nginx, client side (keepalive_timeout) 75 s 1,000 (since 1.19.10) Server facing browsers or a CDN
nginx, upstream pool 60 s, but no pool at all until configured 1,000 Client facing the origin
Apache httpd 5 s 100 Origin server
Node.js http server 5 s Unlimited Origin server
AWS Application Load Balancer 60 s Not limited by default Server to clients, client to targets
Envoy upstream connections 1 hour Unlimited Client facing services
Firefox 115 s Not limited Browser client

Defaults are inconsistent across the chain, and the most common pairing (a short-timeout origin behind a long-timeout proxy) violates the client-closes-first rule out of the box.

The timeout ladder

A practical framework is to think of timeouts as a ladder that descends toward the requester. Each server-side idle timeout should sit above the client-side idle timeout of whatever connects to it, with a margin of at least a few seconds to absorb clock and scheduling jitter.

  1. Origin application server: the top rung, for example 65 seconds if the proxy in front holds 60.
  2. Load balancer or reverse proxy toward the origin: its upstream idle timeout below the origin's, for example 60 seconds.
  3. CDN or shield to the load balancer: the CDN's origin connection idle timeout below the balancer's client-side timeout.
  4. Browser to CDN edge: least sensitive, because browsers retry idempotent requests on a reset reused connection, but still worth keeping the edge timeout above typical browser idle windows of 1 to 5 minutes for busy pages.

A config pattern for nginx in front of an origin

nginx does not reuse upstream connections by default. It speaks HTTP/1.0 to upstreams and closes after each response. A working pattern, described directive by directive:

  • In the upstream block, add keepalive 64 to cache up to 64 idle connections per worker. Size it near your steady-state concurrent upstream requests per worker, not your peak.
  • Set keepalive_timeout inside the upstream block (available since 1.15.3) a few seconds below the origin's idle timeout.
  • Set keepalive_requests in the upstream block to recycle connections periodically, for example 1,000, so new backends added by autoscaling receive traffic.
  • In the location that proxies, set proxy_http_version 1.1 and clear the Connection request header with proxy_set_header Connection and an empty string. Without both, the upstream pool stays empty.
  • For a Node.js origin behind it, raise server.keepAliveTimeout to 65000 milliseconds and server.headersTimeout to 66000, so the header timer never fires before the idle timer.
  • On both edge and origin hosts, set net.ipv4.tcp_slow_start_after_idle to 0 to keep congestion windows warm across idle gaps.

Request coalescing compounds these gains. BlazingCDN includes origin shield and request coalescing on every plan, so concurrent misses for the same object collapse into one origin fetch, and the origin keeps fewer, busier persistent connections warm instead of many cold ones.

Trade-offs and failure modes of long-lived connections

Longer timeouts are not free. Each idle connection holds a file descriptor and kernel socket buffers, and in thread-per-connection servers such as Apache with the prefork MPM, it holds a whole worker. That is why Apache ships with a 5 second timeout: a 75 second idle window on prefork can exhaust MaxRequestWorkers under modest traffic. Event-driven servers such as nginx and Envoy carry idle connections cheaply, which is why their defaults are much longer.

Reuse also fights load balancing. A persistent connection pins all of its requests to one backend, so after a scale-out the new instances receive almost nothing until old connections recycle. A request cap per connection (keepalive_requests in nginx, max requests per connection in Envoy) is the fix, at the cost of a handshake every N requests. At 1,000 requests per connection that is a 0.1% handshake rate, which is negligible.

Middleboxes break the ladder silently. NAT gateways and stateful firewalls track idle flows on their own timers; AWS NAT Gateway, for example, drops flows idle for 350 seconds. A pool that holds connections longer than that discovers the drop only when a request times out rather than resets, which shows up as a long-tail latency spike at p99, not as an error. Keep pool idle timeouts below every middlebox timer on the path.

Finally, retries hide the problem. Most proxies retry idempotent requests after a reset on a reused connection, so a broken ladder appears as a p99 bump of one extra round trip and a slightly elevated upstream connect rate rather than as 502s. Non-idempotent requests such as POST are not retried, so those surface the errors first.

How to verify connection reuse on your own stack

Measure the reuse ratio directly instead of trusting configuration. Three checks cover most stacks:

  • In nginx access logs, log upstream_connect_time and connection_requests. A reused upstream connection reports a connect time of zero; the share of zero values is your origin reuse ratio.
  • Count TIME-WAIT sockets toward the origin with ss on the proxy host. A count growing in proportion to request rate means connections are closing after each response.
  • In Chrome DevTools, enable the Connection ID column in the Network panel. Repeated IDs across requests to the same host confirm browser-side reuse.

Who should tune this, and who can leave defaults alone

Tuning pays off when origin round trips are long (cross-region shields, 50 ms or more), when miss rates run to hundreds of requests per second per origin, when origins run on Node.js, Apache or other short-timeout defaults behind long-timeout proxies, or when objects are large enough that slow start dominates transfer time. Software downloads, video segments and API backends behind a CDN all fit.

Low-traffic sites behind a single reverse proxy with HTTP/2 to browsers rarely benefit. Browser-to-edge reuse already works, and a few cold origin connections per minute cost nothing measurable. Fix the ladder order everywhere, since that prevents errors, but skip pool sizing and sysctl work until request rates justify it.

FAQ: HTTP keep-alive and persistent connections

Is HTTP keep-alive enabled by default in HTTP/1.1?

Yes, HTTP/1.1 connections are persistent by default, and either side ends one by sending "Connection: close". The explicit "Connection: keep-alive" opt-in only mattered for HTTP/1.0. Servers still enforce their own idle timeouts and request caps, so default persistence does not guarantee long reuse; nginx closes idle client connections after 75 seconds and Apache httpd after 5 seconds.

Should I send the Keep-Alive header in HTTP/2 responses?

No, HTTP/2 and HTTP/3 forbid the Keep-Alive and Connection headers, and RFC 9113 requires endpoints to treat messages containing them as malformed. HTTP/2 connections are persistent and multiplexed by design, and they end idle sessions with a GOAWAY frame. Proxies translating from an HTTP/1.1 origin must strip these headers before forwarding responses to HTTP/2 clients.

Why does a short keep-alive timeout on my origin cause 502 errors?

A short HTTP keep-alive timeout on the origin causes 502 errors because the proxy in front reuses a connection the origin has just closed. The request hits a reset socket and the proxy reports a bad gateway. The fix is ordering: the origin's idle timeout must exceed the proxy's upstream idle timeout, for example 65 seconds behind a 60 second load balancer.

What is the difference between HTTP keep-alive and TCP keepalive?

HTTP keep-alive reuses one connection for multiple HTTP requests, while TCP keepalive sends periodic probes to detect a dead peer on an idle socket. They are independent: enabling SO_KEEPALIVE does not make HTTP reuse connections. TCP keepalive on Linux waits 7,200 seconds before the first probe by default, far longer than typical HTTP idle timeouts of 5 to 75 seconds.

How many requests should a persistent connection serve before closing?

A persistent connection serving around 1,000 requests before closing is a reasonable default, matching nginx since version 1.19.10. The cap exists so connections recycle and autoscaled backends receive new traffic. At 1,000 requests per connection, handshakes add overhead to only 0.1% of requests, so lower caps mainly matter when backends scale in and out quickly.

Measure your origin reuse ratio this week

Add upstream_connect_time to your proxy access logs for one day, then compute the share of origin requests with a connect time of zero. Compare it against the timeout ladder: list every idle timeout from browser to origin, confirm each server side sits above its client, and check the TIME-WAIT count on the proxy at peak. If reuse sits below 90% at hundreds of misses per second, fix pool sizing and timeout order before touching anything else, then set tcp_slow_start_after_idle to 0 and remeasure p95 miss latency.

To compare your edge against a reference, the BlazingCDN performance metrics page publishes a public test file and download figures measured June 2026, including 24 ms and 1.11 Gb/s in North America on a 100 MB HTTPS download.

Heavy traffic.
Light bill.

The CDN for video and large traffic