Frontend Labs
⋯

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:

  1. Alt-Svc response header (RFC 7838): Alt-Svc: h3=":443"; ma=86400 means "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.
  2. The DNS HTTPS record (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 TLSNextProto map is Go's documented way to switch off HTTP/2 in a client. With it, ALPN offers only http/1.1.
  • Not every major site uses HTTP/3. At the time of testing, GitHub advertised it neither in Alt-Svc nor 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-go library. 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:

  1. Run it against five sites you use daily. Which publish an HTTPS record? Which advertise h3 only in Alt-Svc?
  2. With curl --http3 (if your curl supports it), compare curl -w '%{http_version} %{time_appconnect}\n' over HTTP/3 and HTTP/2 to the same site.
  3. 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 to h2 with 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

  1. Where in the handshake are HTTP/1.1 and HTTP/2 chosen, and why does that cost no extra round trip?
  2. Give two ways a browser learns that a site supports HTTP/3, and say which one helps on the first visit.
  3. What does 421 Misdirected Request tell a browser?
  4. 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-Svc or the DNS HTTPS record.
  • 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.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36