Part 8 — Expert operations: running HTTPS in production
The lifecycle is not only something to understand once. In production, you need to operate it while browsers, CDNs, certificates, DNS records, proxies, mobile networks, and backend services are changing underneath you. This part turns the earlier chapters into an expert troubleshooting and operations playbook.
Chapter 14 — HTTP version negotiation: HTTP/1.1, HTTP/2, HTTP/3, Alt-Svc, and fallbacks
In one sentence
Modern HTTPS is negotiated, not assumed: the browser and server choose between
HTTP/1.1, HTTP/2, and HTTP/3 using TLS ALPN, QUIC, Alt-Svc, DNS records, and
fallback behavior.
Why this matters
Two users can visit the same URL and use different protocol stacks. One may use HTTP/3 over QUIC/UDP, another HTTP/2 over TLS/TCP, and another HTTP/1.1 because a middlebox blocks UDP. Expert diagnosis starts by proving which path the client actually used. Performance, failure modes, and even which load balancer features apply all depend on it.
How it actually works
HTTP/1.1 over TLS/TCP:
DNS → TCP → TLS(ALPN=http/1.1) → HTTP request/response
HTTP/2 over TLS/TCP:
DNS → TCP → TLS(ALPN=h2) → HTTP/2 frames and streams
HTTP/3 over QUIC/UDP:
discover h3 support via Alt-Svc or HTTPS/SVCB DNS
DNS → UDP/QUIC Initial + TLS 1.3 → ALPN=h3 → HTTP/3 streams
ALPN decides between HTTP/1.1 and HTTP/2, inside the TLS handshake. The
client lists what it speaks (h2, http/1.1) in the ClientHello, and the
server picks one in its reply. No extra round trip is needed. A server or
TLS-terminating proxy that doesn't support h2 simply picks http/1.1.
HTTP/3 can't be negotiated that way, because it runs over UDP. A client that has only a TCP connection doesn't know QUIC is available. There are two ways to find out:
Alt-Svcresponse header (RFC 7838):Alt-Svc: h3=":443"; ma=86400means "next time, you may use HTTP/3 on UDP port 443, and remember this for a day". The first visit uses TCP, and later visits can use QUIC.- The DNS
HTTPSrecord (type 65, RFC 9460) publishes the same information in DNS (alpn="h3,h2"), so even the first connection can go straight to HTTP/3. It can also carry IP hints and Encrypted Client Hello keys.
Fallback. Browsers race QUIC against TCP (much like Happy Eyeballs for IPv6), and if UDP/443 is blocked or QUIC fails, they quietly use HTTP/2 over TCP and remember that QUIC is broken on this network for a while. That makes HTTP/3 rollouts safe, and it also makes HTTP/3 problems invisible unless you look for them.
Connection coalescing (HTTP/2 and HTTP/3). A browser may reuse one
connection for different hostnames if they resolve to the same IP and the
certificate covers both names. www.example.com and static.example.com can
share a connection, saving a handshake. It also means a request can reach a
server that never expected traffic for that hostname. A server answers
421 Misdirected Request to tell the browser to open a separate connection.
Build it in Go (25 min) — see how a site advertises each version
This lab requests a site twice (once allowing only HTTP/1.1, once allowing
HTTP/2), reads Alt-Svc, and decodes the DNS HTTPS record by hand:
// protoprobe: which HTTP versions does a site offer, and how is each one
// discovered? Compares HTTP/1.1 and HTTP/2 over TLS (ALPN), reads Alt-Svc,
// and decodes the DNS HTTPS record (type 65) that can advertise h3 up front.
//
// go run ./protoprobe cloudflare.com
// go run ./protoprobe www.google.com
package main
import (
"crypto/tls"
"encoding/binary"
"fmt"
"math/rand/v2"
"net"
"net/http"
"os"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Println("usage: protoprobe HOST")
os.Exit(2)
}
host := os.Args[1]
url := "https://" + host + "/"
// 1. Force HTTP/1.1: a non-nil, empty TLSNextProto map disables HTTP/2,
// so ALPN offers only "http/1.1".
h1 := &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}}
// 2. Allow HTTP/2: ALPN offers "h2" first.
h2 := &http.Transport{ForceAttemptHTTP2: true}
for _, t := range []struct {
name string
rt http.RoundTripper
}{{"HTTP/1.1 only", h1}, {"HTTP/2 allowed", h2}} {
c := &http.Client{Transport: t.rt, Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
start := time.Now()
resp, err := c.Get(url)
if err != nil {
fmt.Printf("%-15s error: %v\n", t.name, err)
continue
}
resp.Body.Close()
fmt.Printf("%-15s -> %-8s %s in %v\n", t.name, resp.Proto, resp.Status, time.Since(start).Round(time.Millisecond))
if t.name == "HTTP/2 allowed" {
if alt := resp.Header.Get("Alt-Svc"); alt != "" {
fmt.Printf("%-15s Alt-Svc: %s <- HTTP/3 offered for NEXT time\n", "", alt)
} else {
fmt.Printf("%-15s no Alt-Svc header: no HTTP/3 advertised over HTTP\n", "")
}
}
}
// 3. The DNS HTTPS record lets a browser learn about h3 BEFORE connecting.
alpn, err := httpsRecordALPN(host)
switch {
case err != nil:
fmt.Println("HTTPS record: lookup failed:", err)
case alpn == nil:
fmt.Println("HTTPS record: none published")
default:
fmt.Printf("HTTPS record: alpn=%v <- usable on the FIRST connection\n", alpn)
}
fmt.Println("\n(Go's standard library has no HTTP/3 client; browsers, curl --http3, and")
fmt.Println(" libraries such as quic-go do. This tool shows how h3 is DISCOVERED.)")
}
// httpsRecordALPN sends a raw DNS query for type 65 (HTTPS, RFC 9460) and
// returns the alpn SvcParam of the first record. See the TCP/IP guide's
// dnsquery lab for the wire format.
func httpsRecordALPN(host string) ([]string, error) {
q := make([]byte, 12, 512)
id := uint16(rand.N(1 << 16))
binary.BigEndian.PutUint16(q[0:], id)
binary.BigEndian.PutUint16(q[2:], 0x0100) // recursion desired
binary.BigEndian.PutUint16(q[4:], 1)
for _, l := range strings.Split(strings.TrimSuffix(host, "."), ".") {
q = append(q, byte(len(l)))
q = append(q, l...)
}
q = append(q, 0, 0, 65, 0, 1) // QTYPE=65 (HTTPS), QCLASS=IN
c, err := net.Dial("udp", "1.1.1.1:53")
if err != nil {
return nil, err
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
c.Write(q)
m := make([]byte, 1232)
n, err := c.Read(m)
if err != nil {
return nil, err
}
m = m[:n]
if n < 12 || binary.BigEndian.Uint16(m) != id {
return nil, fmt.Errorf("bad reply")
}
an := int(binary.BigEndian.Uint16(m[6:]))
off := skipName(m, 12) + 4 // skip the question
for i := 0; i < an && off > 0 && off+10 <= n; i++ {
off = skipName(m, off)
typ := binary.BigEndian.Uint16(m[off:])
rdlen := int(binary.BigEndian.Uint16(m[off+8:]))
rd := off + 10
off = rd + rdlen
if typ != 65 || off > n {
continue // e.g. a CNAME in front of the HTTPS record
}
// RDATA: SvcPriority(2) TargetName(name) then key(2) len(2) value...
p := skipName(m, rd+2)
for p+4 <= off {
key, l := binary.BigEndian.Uint16(m[p:]), int(binary.BigEndian.Uint16(m[p+2:]))
v := m[p+4 : min(p+4+l, off)]
if key == 1 { // alpn: a list of length-prefixed protocol IDs
var out []string
for j := 0; j < len(v); j += 1 + int(v[j]) {
out = append(out, string(v[j+1:min(j+1+int(v[j]), len(v))]))
}
return out, nil
}
p += 4 + l
}
return []string{}, nil // record exists, no alpn key
}
return nil, nil
}
// skipName returns the offset just after a (possibly compressed) DNS name.
func skipName(m []byte, off int) int {
for off < len(m) {
l := int(m[off])
switch {
case l == 0:
return off + 1
case l&0xC0 == 0xC0:
return off + 2
default:
off += 1 + l
}
}
return -1
}
Real results:
$ go run ./protoprobe cloudflare.com
HTTP/1.1 only -> HTTP/1.1 301 Moved Permanently in 129ms
HTTP/2 allowed -> HTTP/2.0 301 Moved Permanently in 251ms
Alt-Svc: h3=":443"; ma=86400 <- HTTP/3 offered for NEXT time
HTTPS record: alpn=[h3 h2] <- usable on the FIRST connection
$ go run ./protoprobe github.com
HTTP/1.1 only -> HTTP/1.1 200 OK in 263ms
HTTP/2 allowed -> HTTP/2.0 200 OK in 114ms
no Alt-Svc header: no HTTP/3 advertised over HTTP
HTTPS record: none published
What to notice:
- A non-nil, empty
TLSNextProtomap is Go's documented way to switch off HTTP/2 in a client. With it, ALPN offers onlyhttp/1.1. - Not every major site uses HTTP/3. At the time of testing, GitHub
advertised it neither in
Alt-Svcnor in DNS. "Every user gets HTTP/3" is never a safe assumption. - Go's standard library has no HTTP/3 implementation. Go servers usually
sit behind a CDN or proxy that terminates QUIC, or use the
quic-golibrary. That's why Go services so often speak HTTP/2 or HTTP/1.1 to their own load balancer even when users see HTTP/3.
Exercises:
- Run it against five sites you use daily. Which publish an HTTPS record?
Which advertise
h3only inAlt-Svc? - With
curl --http3(if your curl supports it), comparecurl -w '%{http_version} %{time_appconnect}\n'over HTTP/3 and HTTP/2 to the same site. - Block UDP/443 on your machine (macOS:
pfctl; Linux:sudo iptables -A OUTPUT -p udp --dport 443 -j DROP), load a site in Chrome, and check the Protocol column in DevTools → Network. It falls back toh2with no visible error. Remove the rule afterwards.
Real-world scenario
Mobile users report intermittent slowness after an HTTP/3 rollout, but office users look fine. The office firewall blocks UDP/443, so office clients quietly fall back to HTTP/2. Mobile clients use QUIC and hit a packet-loss or MTU issue: QUIC packets are UDP datagrams that must fit the path MTU, typically 1,200–1,350 bytes. If you only test from the office, you debug the wrong protocol.
Common mistakes
- Saying "the site uses HTTP/2" or "the site uses HTTP/3" without checking the actual client, network, and request.
- Forgetting that HTTP/3 can fall back when UDP is blocked.
- Assuming a server-side protocol rollout affects all users at once.
- Forgetting connection coalescing when two hostnames share a certificate and an IP but are served by different backends.
- Load-balancing HTTP/2 or gRPC per connection (L4) and wondering why traffic is uneven (TCP/IP guide, Chapter 57).
Check yourself
- Where in the handshake are HTTP/1.1 and HTTP/2 chosen, and why does that cost no extra round trip?
- Give two ways a browser learns that a site supports HTTP/3, and say which one helps on the first visit.
- What does
421 Misdirected Requesttell a browser? - Why might a Go service speak HTTP/2 to its load balancer while users get HTTP/3?
Key Takeaways
- ALPN negotiates h1/h2 inside TLS. HTTP/3 is discovered via
Alt-Svcor the DNSHTTPSrecord. - Clients fall back silently, so always find out which protocol a failing user actually used.
- HTTP/2 and HTTP/3 multiplex many requests on one connection: great for latency, but it changes load balancing and failure modes.