Every HTTP request is expensive before a single byte of payload moves: a cold connection costs one RTT for the TCP handshake, one for TLS, and then the request itself. On a 40 ms RTT link, that's 80 ms of pure overhead before the server even reads your GET. Keep-alive exists to amortize that cost across many requests. But it's a timer-driven bargain between two machines, and it works exactly as well as the settings on both sides.
The three-way handshake you keep paying for
Look at any fresh connection from the client side:
$ curl -v https://api.example.com/resource 2>&1 | grep -E "TCP|TLS|Re-using"
* Connected to api.example.com (104.21.62.13) port 443
* ALPN: offers h2,http/1.1
* TLS 1.3 ...
* Connected, TLS handshake completed in 0.04s
> GET /resource HTTP/1.1The SYN, SYN-ACK, ACK, then the TLS handshake — on an RTT of 40 ms that's roughly 80 ms before the request line leaves the machine. Now do it for 40 requests (a typical page load) and the connection setup alone is 3.2 seconds. That's what keep-alive eliminates: the second request on the same connection costs zero RTTs of setup. It is the single cheapest performance fix in all of networking, because it doesn't make anything faster — it just stops re-paying for the same handshake.
What the server actually does
Keep-alive on the server is not a feature — it's a policy about idle time. The server
holds the connection open, with the TCP socket parked, waiting for the next request. But
it cannot wait forever: each parked socket consumes a file descriptor, kernel memory, and
a slot in the accept queue. So every server runs a clock. On nginx that's
keepalive_timeout, on Apache KeepAliveTimeout, on Node the socket's setTimeout:
keepalive_timeout 75s; # how long to hold an idle connection
keepalive_requests 1000; # max requests per connection, then closeThese two knobs define the contract. keepalive_timeout answers "how long do I hold an
idle socket before I close it?" and keepalive_requests answers "how many requests does
one connection get before I recycle it?" — the second exists because a very long-lived
connection accumulates state (head-of-line risk, balancer skew, and, on HTTP/1.1,
sequential coupling).
The four-way negotiation you never see
Whether a connection gets reused is decided by four independent clocks:
client idle timeout ──┐
server keepalive ──┼──> connection closes when ANY timer fires
client app close ──┤
server max requests ──┘If the server's timeout is 75 s and the client's pool timeout is 60 s, the client wins:
it reaps the socket and starts a new handshake at exactly 60 s of idle. If the client's
timeout is longer, it gets a Connection: close and a FIN mid-use — a one-time latency
spike. There is no negotiation message; each side closes when its own timer fires, and
the other side discovers it on the next write. That's why "connection reset" errors in a
pool almost always mean timer mismatch, not a server crash.
Observe it yourself
$ ss -tan | grep 443
ESTAB 0 0 192.168.1.20:51234 104.21.62.13:443
$ curl -o /dev/null -s -w "total: %{time_total}s\n" \
https://api.example.com/ping
total: 0.032s # cold: handshake + TLS + request
$ curl -o /dev/null -s -w "total: %{time_total}s\n" \
https://api.example.com/ping
total: 0.004s # warm: connection reused, ~no setup costcurl reuses connections automatically within a single invocation and shows
Re-using existing connection when it does. The gap between the first and second number
— 0.032 s vs 0.004 s — is the entire value of keep-alive. In ss output you can watch
the socket transition through ESTAB (idle but parked) to FIN-WAIT-2 / TIME-WAIT when
the server's timer fires.
Why HTTP/2 and HTTP/3 changed the terms
Keep-alive solves connection setup cost, but HTTP/1.1 still processes one request at a time per connection — the next request waits for the previous response (head-of-line blocking). HTTP/2 keeps the single reused connection but multiplexes many requests over it; HTTP/3 keeps multiplexing and also survives network changes via QUIC. The reused connection is the foundation of both — keep-alive is not obsolete, it's assumed.
The mental model
- Keep-alive eliminates a fixed per-request tax: 2 RTTs on HTTPS, paid once per connection.
- It's a contract of timers — client pool timeout must be shorter than server
keepalive_timeout, or you'll reap idle sockets and rebuild them. - One socket in
ss, many requests across it: that's the whole trick. - If your p95 shows a bimodal distribution — most requests fast, a few much slower — you're almost certainly watching pool rebuilds: new handshake, new slow start, same server, new timer.