Networking
A request costs round trips before it costs bandwidth. Count the round trips each protocol needs, then remove them: reuse connections, end TLS near the user, and send fewer bytes.
- 1Latency is round trips times the RTT. A new HTTPS connection costs 3 round trips with TLS 1.3, 4 with TLS 1.2.
- 2Reuse connections: a warm connection costs 1 round trip per request and far less CPU than a handshake.
- 3HTTP/2 removes HTTP head-of-line blocking; TCP loss still holds every stream. QUIC removes that too.
- 4The speed of light sets the floor: about 1 ms of RTT per 100 km of fiber.
- Round-trip time (RTT): a packet's trip to the server and back.
- Each handshake waits for a reply, so it costs whole round trips.
- Bandwidth does not shorten a round trip.
A new HTTPS connection costs three round trips with TLS 1.3 before the response starts. A reused connection costs one.
| setup | new | reused | status |
|---|---|---|---|
| Plain HTTP over TCP | 2 | 1 | Not on the internet |
| TCP + TLS 1.2 | 4 | 1 | Old clients only |
| TCP + TLS 1.3 | 3 | 1 | Approved |
| QUIC: HTTP/3 | 2 | 1 | Approved |
| QUIC with 0-RTT data | 1 | 1 | Idempotent requests only |
| TLS 1.3 with 0-RTT data | 2 | 1 | Idempotent requests only |
TLS 1.2, TLS 1.3 and HTTP/2: measured in the lab. QUIC and 0-RTT: derived from RFC 8446, RFC 9000 and RFC 9001. A DNS miss adds lookups before all of them.
I count round trips: 4 for TLS 1.2, 3 for TLS 1.3, 2 for HTTP/3, and 1 on any warm connection.
- distance
- Frankfurt to northern Virginia: 6,550 km on the great circle.
- fiber
- Light in glass covers about 200 km per ms. RTT ≥ 2 × 6,550 / 200 ≈ 66 ms.
- assumed
- Edge 10 ms away; the server takes 50 ms. Both are example values.
Frankfurt to Virginia is at least 66 ms of RTT. A cold TLS 1.3 request pays it three times, so I end TLS at a nearby edge and keep the long link warm.
| 5 requests | total | conns |
|---|---|---|
| TLS 1.2, new each | 1,637 ms | 5 |
| TLS 1.3, new each | 1,240 ms | 5 |
| TLS 1.3, reused | 582 ms | 1 |
- Model: 20, 15 and 7 round trips. 7 × 80 ms = 560 ms.
- At a 1 ms RTT, the TLS 1.2 handshake took 3.9 ms and TLS 1.3 3.2 ms: CPU, not distance.
At an 80 ms RTT, five requests on one warm connection finished in less than half the time of five new TLS 1.3 connections.
Step 1: DNS lookup
- Ask the resolver for the address of the name.
- A cached answer costs nothing on the wire. A miss walks the DNS tree.
- The answer is kept for its TTL.
If it fails
A long TTL keeps clients on the old address after a failover, until the TTL runs out.
DNS is cached, the two handshakes end at the load balancer, and everything behind the balancer runs on warm, pooled connections.
HTTP/1.1 over TLS 1.3. The client sends its key share in the first hello, so the handshake takes 1 round trip.
| request 1 | TCP handshake | TLS handshake | request and response | total |
|---|---|---|---|---|
| measured | 81.2 ms | 84.5 ms | 82.0 ms | 248 ms |
Measured on loopback through a proxy that delays each direction by half the RTT. At 1 ms, CPU time is a large share of each phase.
Each handshake adds whole round trips. Reuse removes them from every request after the first, and the saving grows with the RTT.
| layout | slow response | lost segment |
|---|---|---|
| HTTP/1.1, 1 connection | All wait | All wait |
| HTTP/1.1, 6 connections | Only the slow one | 1 of 6 waits |
| HTTP/2, 1 connection | Only the slow one | All wait |
| HTTP/3 (QUIC) | Only the slow one | Its stream waits |
In the lab, the last fast HTTP/1.1 request finished at 238 ms. With HTTP/2 and a lost segment, every response took over 200 ms. HTTP/3: derived from RFC 9000. The 200 ms resend matches the Linux minimum retransmission timeout.
HTTP/2 fixes head-of-line blocking at the HTTP layer, but TCP still delivers bytes in order. One lost segment holds every stream on the connection, which QUIC avoids.
| method | how | status |
|---|---|---|
| Polling | Ask every N seconds; most answers are empty. | Wasteful |
| Long polling | The server holds the request until data arrives. One message per response. | Fallback |
| Server-sent events | One HTTP response stays open. Server to client only; the browser reconnects. | Feeds |
| WebSocket | An HTTP upgrade, then frames both ways on one connection. | Two-way |
| gRPC streaming | HTTP/2 streams: server, client or both directions. | Between services |
Over HTTP/1.1, browsers limit open connections per host, so many SSE tabs can block. Over HTTP/2, each stream shares one connection.
For server-to-client updates I use server-sent events. I use WebSocket when the client also sends often, and long polling only where neither works.
setup(protocol): // round trips before the request leaves
TCP + TLS 1.2: 1 + 21
TCP + TLS 1.3: 1 + 12
QUIC (HTTP/3): 0 + 1 // transport and TLS in one3
QUIC with 0-RTT: 0 + 0 // request in the first packet4
round_trips(protocol, n, reuse):
IF reuse: RETURN setup + n // pay the setup once5
RETURN n × (setup + 1) // pay it on every request- 1TLS 1.2: hello and key exchange, then the Finished messages.
- 2TLS 1.3 sends the key share in the first hello. HTTP/2 adds nothing: ALPN picks it inside the handshake.
- 3QUIC carries the TLS 1.3 handshake in its own first packets.
- 4Uses a key from an earlier session. An attacker can replay this packet.
- 5The lab checks every phase of every request against this model at an 80 ms RTT.
Tested source Go: the model
// Setup is the round trips a new connection costs before its first request can leave:
// the TCP handshake, then the TLS handshake.
func Setup(p Proto) (tcp, tls int) {
switch p {
case TCP, HTTP1:
return 1, 0 // SYN, SYN-ACK
case TLS12:
return 1, 2 // hello and key exchange, then Finished both ways
case TLS13, H2:
return 1, 1 // the client sends its key share in the first hello
case H3:
return 0, 1 // QUIC runs the transport and TLS 1.3 handshakes together
case H30RTT:
return 0, 0 // the request rides in the first packet, with a resumed key
}
panic(fmt.Sprintf("unknown protocol %q", p))
}
// RoundTrips is the total for n requests sent one after another.
func RoundTrips(p Proto, n int, reuse bool) int {
tcp, tls := Setup(p)
if reuse {
return tcp + tls + n // one setup, then 1 round trip per request
}
return n * (tcp + tls + 1) // every request pays the setup again
}
My latency estimate is setup round trips plus one per request, times the RTT. Reuse pays the setup once.
transfer_round_trips(bytes): // new connection, no loss
segments = ceil(bytes / 1460) // MTU 1500 - 40 bytes of headers1
window = 10 // initial window, segments2
rounds = 0
WHILE segments > 0:
segments -= window
window = window × 2 // slow start doubles it3
rounds += 1
RETURN rounds- 1Ethernet MTU 1,500 minus a 20-byte IPv4 header and a 20-byte TCP header.
- 2RFC 6928: 10 segments, about 14.6 KB.
- 3With no loss, the window doubles each round trip until a threshold.
Tested source Go: slow start
// TransferRounds is the round trips a new connection needs to deliver n bytes in slow start,
// with no loss: the window starts at 10 segments and doubles every round trip.
func TransferRounds(n int) int {
segs := (n + MSS - 1) / MSS
rounds, cwnd := 0, InitCwnd
for segs > 0 {
segs -= cwnd
cwnd *= 2
rounds++
}
return max(rounds, 1)
}
| JSON lines | raw KB | gzip KB | round trips, raw | gzip |
|---|---|---|---|---|
| 10 | 1.4 | 0.4 | 1 | 1 |
| 100 | 14 | 2.6 | 1 | 1 |
| 1,000 | 139.8 | 22 | 4 | 2 |
| 10,000 | 1,398 | 214.1 | 7 | 4 |
A new connection sends 10 segments, about 14 KB, in its first round trip, then doubles. Compression cuts round trips, not only bytes.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Service | HTTP client pool with keep-alive | One setup, then 1 round trip per request. No handshake CPU per call. | Calls to payment and search providers |
| Service | HTTP/2 or gRPC: many streams on one connection | Concurrent calls without one connection each; no HTTP head-of-line blocking. | Service-to-service calls, streaming |
| Service | gzip or Brotli on text bodies | Fewer segments, so fewer slow-start round trips on new connections. | APIs, HTML, logs |
| Postgres | Connection pool in the service, or a pooler such as PgBouncer | Each Postgres connection is a server process, with TCP, TLS and authentication. A pool pays that once. | Limits on total connections |
| Postgres | Pipeline mode in the client library | Send several queries without waiting for each result: fewer round trips. | Bulk loads |
| Redis | Pipelining | Many commands in one round trip. | Batch reads, cache warm-up |
| DNS | TTL on each record | Resolvers cache the answer, so most requests skip the lookup. A short TTL makes a change take effect fast. | Failover, traffic steering |
| Load balancer | TLS termination, keep-alive to backends | Handshakes happen once at the edge of the network; backends get warm connections. | Certificates in one place |
| CDN edge | Points of presence near users | The handshake round trips are short; the long link to the origin stays warm. | Caching, DDoS absorption |
| QUIC, HTTP/3 | 1-RTT setup, 0-RTT resumption, independent streams | One round trip less on new connections; a loss holds only its stream. | Mobile clients on lossy networks |
I keep connections warm at every hop. The service pools its connections, the balancer keeps them alive, and TLS ends at an edge near the user.
| event | result | why it stays correct | saved by |
|---|---|---|---|
| A deploy drops every connection, and all clients reconnect at once | A connection storm: each handshake costs about 12 times a warm request in CPU. | Clients reconnect after a random delay, so the handshakes spread over time. | Jittered backoff |
| A client opens and closes a connection per call | Closed sockets hold ports in TIME_WAIT. The lab's loop used all 16,384 ports in seconds. | A pool keeps a few connections open and reuses them. | Connection pool |
| A failover changes an IP, but the DNS TTL is 1 day | Clients keep the old address until their cached record expires. | Lower the TTL one old TTL ahead, or fail over behind an address that does not change. | DNS TTL |
| One slow response on an HTTP/1.1 connection | Every request queued behind it waits. | HTTP/2 streams let the fast responses pass. | HTTP/2 |
| One lost segment on an HTTP/2 connection | All streams wait for the resend. | QUIC delivers each stream in order on its own. | HTTP/3 |
| The load balancer closes idle connections first | The next request on that pooled connection fails. | Set the pool's idle timeout below the balancer's. Retry an idempotent request once. | Idle timeout |
| A path drops large packets and blocks ICMP | Small requests work; large responses stall. | Clamp the TCP MSS at the tunnel, or let the host probe the path MTU. | MSS clamping |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One server with keep-alive, and pools to the database and to other services. | About 26,300 requests a second on warm connections in the lab, against about 2,100 with a new TLS connection each. | One server is not enough, or certificates and handshakes spread over many hosts. |
| 2 | A load balancer that ends TLS and keeps warm connections to the servers. | Handshakes in one tier; servers scale behind it. | Many service-to-service calls, each holding its own HTTP/1.1 connection. |
| 3 | HTTP/2 or gRPC between services. | Many concurrent calls on few connections; streaming calls. | Users far away: three long round trips before the first byte. |
| 4 | Edges near users, with warm links to the origin. | Short handshakes everywhere; one long round trip per request. | Mobile users on lossy networks; streams held by one lost segment. |
| 5 | HTTP/3 at the edge, with HTTP/2 as the fallback. | 1-RTT setup, 0-RTT for idempotent requests, no TCP head-of-line blocking. | Top of the ladder. |
Rates: medians of three runs on an 8-core laptop, 8 clients, client and server on the same machine, so each handshake pays both sides' CPU. Runs varied by up to 1.6 times. The storm is an example: 100,000 clients that reconnect within 10 s.
I start with one server and keep-alive. I add a load balancer that ends TLS, then HTTP/2 between services, then edges near users, and HTTP/3 last.
0 of 10 known
How many round trips does a new HTTPS connection take before the first response byte, with TLS 1.3 and with TLS 1.2?
Why does connection reuse save more at 80 ms than at 1 ms?
What is head-of-line blocking in HTTP/1.1, and how does HTTP/2 remove it?
When can HTTP/2 on one connection be slower than six HTTP/1.1 connections?
What does QUIC change?
What is the risk of 0-RTT data?
You lower a DNS TTL from 1 day to 60 s just before a failover. Why do clients still go to the old address?
A service opens a new connection for every call to another service, 2,000 calls a second. What breaks first?
A live score feed only sends from server to client. WebSocket or server-sent events?
Why gzip a 139.8 KB JSON response when bandwidth is plentiful?
- fiber
- About 200 km per ms one way: 1 ms of RTT per 100 km, at best.
- ocean
- Frankfurt to Virginia: RTT of at least 66 ms.
- setup
- New HTTPS: 3 RTT with TLS 1.3, 4 with TLS 1.2, 2 with HTTP/3. Reused: 1.
- at 80 ms
- One request: 248 ms new, 81 ms reused.
- CPU
- About 2,100 new TLS connections a second against 26,300 warm requests: about 12 times.
- window
- 10 segments of 1,460 bytes in the first round trip: 14.6 KB.
- ports
- macOS: 16,384 ephemeral ports, 30 s in TIME_WAIT, so about 546 new connections a second to one address. Linux: 28,232 ports, 60 s, about 470.
Cited: RFC 6928, RFC 8446, RFC 9000, RFC 9001, and the operating system defaults. Measured: Go servers on an 8-core laptop.