The Runtime Theory
Networking

HTTP Keep-Alive and Connection Reuse: What the Machine Actually Does

Why HTTP/1.1 needs Connection: keep-alive, what the server timeout does, and how curl and ss reveal whether your connections are being reused or rebuilt.

The Runtime Theory Team3 min read#http#keep-alive#connections#curl#networking
On this page

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:

bash
$ 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.1

The 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:

bash
keepalive_timeout 75s;        # how long to hold an idle connection
keepalive_requests 1000;      # max requests per connection, then close

These 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:

text
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

bash
$ 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 cost

curl 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.