Loading...
Loading...
TLS handshake, HTTP/1.1 vs HTTP/2 vs HTTP/3, keep-alive, and connection pooling
Last topic cut ceremony to 1 RTT for new QUIC visits and 0-RTT for returning ones, with loss isolated per stream and roaming on one connection ID. But most backend traffic still rides TCP with its 1-RTT SYN plus 1 to 2 RTT of TLS, and every byte on either transport travels readable without encryption. TCP hands you raw, reliable bytes and that is all. Nobody verified who sent them, nobody encrypted them, and every fresh connection pays the full handshake toll again. TLS, meaning Transport Layer Security, the encryption layer that authenticates the server and scrambles bytes, HTTP, meaning the request-response protocol that frames those bytes, and connection pooling, meaning reusing warm connections instead of rebuilding them, each erase one of those pains, in that order.
TCP handshake → TLS handshake → HTTP request/response → keep-alive reuse 1 RTT 1-2 RTT 1 RTT per exchange 0 RTT (reuse) ───────────────────────────────────────────────────────────────────── Without pooling: 3-4 RTT before first byte With TLS 1.3 + keep-alive + HTTP/2: 1 RTT, then 0 for next requests
RTT means one round trip, there and back. On an 80ms path, 3–4 RTT is 240–320ms of ceremony before the app byte moves. The rejected per-request connection pays it every time.
Client Server
ClientHello ──────────────►
◄────────────── ServerHello
Certificate
ServerKeyExchange
ServerHelloDone
ClientKeyExchange
ChangeCipherSpec
Finished ──────────────►
◄────────────── ChangeCipherSpec
Finished
[Application Data]The certificate, meaning the server's signed identity document, plus two full exchanges cost 2 round trips before app data. Every cold connection pays it.
Client Server
ClientHello
+ key_share
+ early_data* ─────────────►
◄────────────── ServerHello
+ key_share
{EncryptedExtensions}
{Certificate}
{Finished}
{Finished} ──────────────►
[Application Data]
* 0-RTT replays if enabled, idempotent GET onlyTLS 1.3 guesses the key share up front, cutting crypto to 1 round trip. 0-RTT, meaning data on the very first packet for returning clients, saves one more, but replays are possible, so it fits only safe repeated reads.
| Version | Wire model | Head-of-line blocking | Use when |
|---|---|---|---|
| HTTP/1.1 | Text, 1 request per TCP connection (pipelining broken) | TCP blocking; 6 connections per origin in browsers | Simple backends, curl, webhooks |
| HTTP/2 | Binary frames, streams multiplexed on 1 TCP | Still TCP blocking, so one lost packet stalls all streams | Browser pages, gRPC (HTTP/2 transport) |
| HTTP/3 | QUIC (UDP), streams over independent QUIC streams | No TCP blocking; loss on stream N does not block stream M | Lossy mobile, edge, QUIC-aware balancer |
Multiplexing means interleaving many streams on one connection. Without it, loading 50 assets needs 8 or more TCP connections, each needing TLS. With it, one connection carries 50 interleaved streams, amortizing one handshake across all of them.
Head-of-line blocking means one lost packet freezes everything behind it. On LTE with 2% loss, HTTP/2 throughput collapses from that freeze. QUIC keeps independent streams flowing, cutting p95 page load, where p95 means the slowest 5% threshold, by 15–30%.
ALPN, meaning a TLS extension where client and server agree on the app protocol inside the handshake, negotiates h2 vs http/1.1. Alt-Svc, meaning an HTTP header advertising alternatives, advertises h3.
The rejected client opens a new TCP plus TLS connection per request, burning 3–4 round trips and a file descriptor, meaning an OS handle per socket, every time, then throws it all away. At 80ms RTT that is 240–320ms per request, so 100 requests a second each pay the toll separately. Pools keep warm connections around and lend them out. Every serious client library already ships one: Go's http.Transport, meaning Go's connection manager, Node's Agent.keepAlive, Java's HttpClient, Python's urllib3.PoolManager. Your job is turning it on and sizing it sanely.
keepAlive: true + maxSockets: 50 per origin, meaning per host-plus-port you call.const agent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 30000,
keepAliveMsecs: 1000
});TIME_WAIT is the cooldown state closed TCP sockets linger in. Fewer fresh connections means fewer of them. Pool per origin, not per path, and pool per process, because 100 workers each with 50 sockets is 5,000 sockets aimed at one upstream.
The handshake toll is not abstract. On a 80ms cross-region path, TCP (1 RTT) plus TLS 1.2 (2 RTT) burns about 240ms, which is 3 times 80, before the request even leaves, on every cold connection, every redirect, every new origin. TLS 1.3 cuts the crypto half to 1 RTT, so about 160ms total, and a warm pooled connection cuts all of it to zero plus the 1 RTT the request itself needs. Multiply by six fresh connections per page and the unpooled page pays over a second of pure ceremony: 6 times 240ms is 1.44 seconds of handshakes before content.
| Setup | Cost at 80 ms RTT | Notes |
|---|---|---|
| Cold TCP + TLS 1.2 | ~240 ms | Full cert chain (~3–5 KB) on every handshake |
| Cold TCP + TLS 1.3 | ~160 ms | Session resumption cuts repeats to ~80 ms |
| Warm pooled + HTTP/2 | ~0 ms + 1 RTT per exchange | Multiplexed streams share the one handshake |
Certificate hygiene matters too: ECDSA certificates, meaning elliptic-curve signatures that verify faster in fewer bytes, beat RSA-2048 on both CPU and wire size, and OCSP stapling, meaning the server attaches its own revocation proof so the client skips a separate lookup, saves a hidden round trip on first visit that otherwise hits an outside responder.
Pools fail in the opposite direction from handshakes: not too slow, but too sticky. A pool that never evicts holds connections to backends that already drained, so deploys look finished while a slice of traffic keeps hitting the old pods. A pool sized at 50 sockets per process across 100 workers opens 5,000 sockets at one upstream (50 times 100), which reads the surge as an attack and starts resetting. And a pool keyed per process never learns DNS changed, pinning traffic to a dead IP long after failover. What breaks first is the deploy: errors cluster right after releases while origin health looks fine, the signature of stale pooled sockets.
Pipes are fast and private now: warm pooled connections cost 0 ms plus 1 RTT, and six cold ones no longer burn 1.44 seconds of ceremony. But every exchange still pays the distance floor, with 80 ms a region away and 150 ms across an ocean. What if the bytes never had to cross the ocean at all?