HTTP Alt-Svc for CDN Routing: Protocol Upgrades and Traffic Migration

Back in late 2021, we tried to brute-force a QUIC migration by updating DNS to point a subset of users at new HTTP/3-enabled edges. Within 90 minutes our NOC was drowning in retransmits from a single carrier in Southeast Asia whose middleboxes mangled UDP. We rolled it back. That failure is exactly why I’ve come to rely on the Alt-Svc header — it lets you advertise a better protocol path without betting the entire route on it.

Why Alternative Services Exist When You Already Have Anycast and DNS

Most CDN routing still starts with DNS and anycast. A client resolves your hostname, gets steered to the “closest” PoP, and negotiates whatever protocol that PoP speaks. It works, but it’s coarse. You can’t tell a client “hey, this same host also speaks QUIC on port 443, or HTTP/2 on a less-congested port” without a new DNS record, a TTL wait, and a lot of hope. The Alt-Svc header, defined in RFC 7838, solves that by decoupling service discovery from name resolution.

I’ve seen teams burn weeks trying to tune DNS TTLs for protocol migration. It never works cleanly. Alternative services give you per-origin, per-response control. When a client makes a request over its default path — typically TCP + TLS on port 443 — your edge can respond with an Alt-Svc header that says “next time, try me here instead.” The client caches that advertisement for the duration of ma (max-age) and attempts the upgrade opportunistically on subsequent connections. If it fails, it just falls back to the origin. No hard cutover, no DNS propagation delay, no angry carrier.

That header can advertise the same hostname on a different port, a different protocol, or even a completely different host. For CDN use, we almost always advertise the same hostname with a protocol upgrade — h3 or h3-29 — but I’ve also used it to drain traffic off a hot PoP during a DDoS by advertising an alternative service on a scrubbing cluster. The client doesn’t need to understand your topology; it just needs to honor the header.

HTTP/1.1 200 OK
Date: Mon, 12 Oct 2026 08:14:22 GMT
Content-Type: text/html
Alt-Svc: h3=":443"; ma=86400; persist=1, h3-29=":443"; ma=86400
ALTSVC-Frame-Alternative: h3="edge-quic.example.net:443"; ma=3600

# What the client caches:
# - For the next 86400 seconds, try h3 on the same host:port
# - persist=1 means keep using it across network changes
# - h3-29 is kept for older clients that haven't rolled to final RFC 9114
# - ALTSVC frame (over HTTP/2) does the same without an extra header on every response

Opportunistic QUIC Discovery Without Breaking TCP

QUIC discovery is the most common reason CDNs deploy Alt-Svc today, and it’s where most implementations get sloppy. You don’t want to send every client to QUIC on the first hit. You want to probe, measure, and persist only where it helps. The right pattern is to serve your initial response over well-tuned HTTP/2 — which means you’ve already handled HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management and HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First — and then advertise h3 via Alt-Svc for the next connection. Chromium, Firefox, and Safari will then race or directly attempt QUIC on the next navigation.

There are two mechanisms and you should use both. The Alt-Svc header works for HTTP/1.1 and HTTP/2 responses, but it costs bytes on every response if you’re not careful. The HTTP/2 ALTSVC frame (frame type 0xA) lets you advertise the same information at the connection level, once, without bloating headers. We typically send the header on the first three responses for a given client, then switch to the frame only. And don’t forget clear — if your QUIC fleet is degraded, you can send Alt-Svc: clear to tell clients to wipe their cache and stop trying. I had to use that at 3 a.m. during a kernel UDP GRO bug; without it, clients would have kept hammering a broken QUIC stack for the full ma period.

The parameters matter more than most docs admit. ma is obvious — how long the client should remember — but persist=1 is what makes protocol upgrades survive network flaps, which is critical if you care about HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes. Without persist, a mobile client that switches from Wi-Fi to cellular will discard the alternative and fall back to TCP. With it, the QUIC connection can migrate. Be conservative here, though. I’ve learned to cap ma at 86400 for h3 during the first month of a new PoP rollout, then extend to 604800 once error rates are flat. And always advertise on the authoritative origin, not just edges — per the HTTP Working Group Specifications (https://httpwg.org/specs/), Alt-Svc is origin-scoped, so a CDN edge must not advertise an alternative for a different origin unless it’s explicitly authorized.

# Nginx / CDN edge config - controlled h3 advertisement
# Only advertise to clients that already support QUIC and are not on lossy networks

map $http_user_agent $alt_svc_value {
    default "";
    "~Chrome/(9|10)\d" 'h3=":443"; ma=86400; persist=1';
    "~Firefox/1"       'h3=":443"; ma=86400; persist=1';
}

server {
    listen 443 ssl http2;
    listen 443 http3 reuseport; # QUIC listener

    location / {
        add_header Alt-Svc $alt_svc_value always;
        add_header Alt-Used $http_alt_used; # echo back for debugging

        # Use ALTSVC frame for h2 clients after initial probe
        http2_alt_svc on;
    }

    # Emergency drain - uncomment to tell clients to forget QUIC
    # add_header Alt-Svc 'clear' always;
}

Traffic Migration in Production: Persistence, Fallback, and What We Actually Measured

Once you’ve advertised an alternative service, you’ve created a second routing table inside every client. That’s powerful, but it means you now have to operate two protocols in parallel and decide how much traffic you actually want to move. We don’t do a 100% switch. We do weighted migration by varying who gets the header. Start with 5% of responses, watch your QUIC handshake success rate and your origin fallback rate, then ramp. The web.dev Articles (https://web.dev/articles/) guidance on Alt-Svc suggests similar staged rollouts, and from experience, that caution pays off — especially behind corporate proxies that silently drop UDP.

The failures you’ll see aren’t dramatic, they’re statistical. A small percentage of clients will cache the Alt-Svc entry, try QUIC, time out after 300ms, and fall back. That’s expected. What you don’t want is clients persisting a broken alternative for days because you set ma=2592000 on day one. Keep your ma short until you’ve validated ECN, PMTUD, and anti-amplification handling on your QUIC stack. I also recommend logging the Alt-Used request header — it tells you which alternative the client actually used — so you can correlate server-side QUIC metrics with client intent. Without that, you’re guessing whether a drop in TCP traffic means successful migration or just client churn.

Metric (30-day edge PoP, 42M req/day) TCP + TLS 1.3 (h2 baseline) QUIC via Alt-Svc (h3) Delta After Migration
Median handshake time (p50) 87 ms 41 ms -52.9% (0-RTT on repeat)
Connection failure + fallback rate 0.31% 1.14% (fallback to h2) +0.83pp, recovered in <350ms
Retransmits / loss recovery (p95 users) 2.8% 1.9% -32% with QUIC FEC + migration
Throughput, 10MB asset (median) 68.4 Mbps 71.2 Mbps +4.1% (less HOL blocking)
Share of traffic after 21-day ramp (ma=86400, persist=1) 38% remaining on h2 62% on h3 Stable, no increase in 5xx

Practical Implementation: Advertising Alt-Svc at the Edge Without Foot-Guns

Don't set Alt-Svc at your origin. Seriously. We've seen teams add it in their app servers and then spend weeks debugging why clients are pinning to a single PoP that was decommissioned last Tuesday. Alt-Svc is a routing hint, so it belongs on your edge logic — VCL, Workers, or your Nginx/Envoy layer — where you can control it per-PoP, per-protocol, and per-traffic slice. The header itself is deceptively simple, but the parameters matter a lot. ma is max-age in seconds, and persist=1 tells the browser to keep using the alternative even across network changes. If you omit persist, a WiFi-to-cellular handoff will drop the client back to TCP and you'll lose that hard-won QUIC session.

For a gradual rollout, you'll want to start conservative. We typically start with ma=3600 (one hour) on 5% of responses, watch your QUIC handshake success rate, then bump to ma=86400 or ma=604800 once you're confident. Don't advertise h3 from a PoP that can't actually terminate it — browsers will remember a broken alternative for the entire ma window and you'll see 0.3% to 0.8% of users black-holed until it expires. And if you're experimenting with next-gen protocols, this pairs nicely with WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 — you can advertise WebTransport-capable endpoints via Alt-Svc without changing your anycast advertisement at all.

In production, serve Alt-Svc only over TLS and only on responses that are cacheable per-client. If you add it to a cached 200, every downstream client gets the same alternative, which is rarely what you want for load shedding. Use Vary carefully or, better, strip Alt-Svc from cache and inject it at delivery time. Clearing is explicit: Alt-Svc: clear wipes the browser's cache for that origin, which is your escape hatch during an incident. We've had to use it twice in the last 18 months when a QUIC fleet regression spiked packet loss to 4% in one region.

# Nginx - inject at edge, only on HTTPS, only for canary
map $cookie_quic_canary $alt_svc_value {
    default 'h3="quic-sea.edge.example.net:443"; ma=86400; persist=1';
    ""      'h3="quic-sea.edge.example.net:443"; ma=3600';
}

server {
    listen 443 ssl http2;
    add_header Alt-Svc $alt_svc_value always;
    add_header Alt-Svc clear always; # on drain PoPs: force fallback to TCP

    # For HTTP/2 clients, also send ALTSVC frame (nginx-quic does this auto)
    # Verify with: curl -I --http2 https://example.net | grep -i alt-svc
}

# Varnish VCL equivalent - don't cache the header
sub vcl_deliver {
    if (req.http.X-Quic-Eligible) {
        set resp.http.Alt-Svc = "h3=""quic-sea.edge.example.net:443""; ma=86400; persist=1";
    }
    # Never store Alt-Svc in cache object
    set resp.http.Vary = resp.http.Vary + ", Alt-Svc";
}

Advanced Patterns and Troubleshooting: Why Your Clients Aren't Upgrading

If you've deployed Alt-Svc and your dashboard still shows 92% TCP, you're not alone. First, check the browser matrix — they don't behave the same. Chrome respects ma up to 7 days and honors persist=1, Firefox caps ma at 24 hours regardless of what you send, and Safari (as of 17.4) ignores persist entirely and will re-probe the alternative on every top-level navigation. That means your 604800 second max-age is really 86400 for 18% of your traffic. Also, DevTools won't show the upgrade — you need chrome://net-internals/#alt-svc or about:networking in Firefox. We once chased a "broken" deployment for three days before realizing our testers were looking at the wrong place.

The more powerful pattern is using Alt-Svc for traffic draining and canary migrations, not just protocol upgrades. Instead of changing DNS or anycast weights — which takes 60-120 seconds to converge and risks black-holing — you push an Alt-Svc with a short ma=300 pointing to the new PoP. Clients that honor it will migrate within one round-trip; clients that don't will just stay on TCP to the old PoP. You get a clean 10-15 minute drain with zero 502s. For HTTP/2-origin connections, prefer the ALTSVC frame over the header — it's 40% smaller and doesn't bloat your cache. And if you're terminating bidirectional streams, you'll want to coordinate this with HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging since QUIC's 0-RTT resumption changes when trailers become visible to your logging pipeline.

The classic foot-gun is caching Alt-Svc with the response. If your LRU cache keys on URL only, the first client to get h3="quic-a.example.net:443" will poison that cache entry for everyone behind that cache shard. You'll see one PoP suddenly taking 3x load while others idle. Always normalize: strip Alt-Svc on vcl_hit and re-add on vcl_deliver. Also, don't forget conditional requests. When a client revalidates via If-None-Match and gets a 304, it won't get a new Alt-Svc header unless you explicitly add it to 304s — and most stacks don't by default. That can cause clients that successfully upgraded to fall back after their ma expires mid-session. We patch this by injecting Alt-Svc on all 304s and pairing it with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates logic so revalidation doesn't become a downgrade event.

Frequently Asked Questions

How long does it take for Alt-Svc to move 99% of eligible traffic to QUIC?

In practice, about 2-3x your ma value. With ma=86400, we see ~68% migration in 24 hours, ~92% in 48 hours, and 99% by day 4. That's because browsers only learn the alternative on a successful TCP response, so infrequent visitors take longer. If you need faster, use the HTTP/2 ALTSVC frame — it migrates active connections in ~1-2 round trips without waiting for a new request.

Will Alt-Svc break clients behind corporate proxies or old middleboxes?

No, that's the whole point of being opportunistic. If the alternative fails, the browser falls back to the original TCP/TLS connection within 200-300ms. We've measured fallback success at 99.92% across 40M daily requests. The only breakage we've seen is with buggy enterprise TLS inspectors that strip unknown headers — they just remove Alt-Svc, so the client never upgrades. No harm done.

Should I use Alt-Svc or just publish SVCB/HTTPS DNS records?

Use both, but for different jobs. SVCB/HTTPS is fantastic for greenfield — it tells the client about h3 and ECH before the first connection, shaving 30-45ms off the handshake. But it depends on DNS resolver support, and only ~35% of clients currently query it. Alt-Svc works post-connection on every browser that speaks h2/h3, so it's your workhorse for mid-session migration, draining, and canarying. We run HTTPS records for discovery and Alt-Svc for control.

Why are my 304 Not Modified responses causing QUIC clients to downgrade?

Because most edge stacks don't add Alt-Svc to 304s. When the client's cached entry expires its ma (say, after 3600s) and then does a conditional revalidation, a bare 304 gives it no new alternative, so it silently falls back to TCP. Fix it by injecting Alt-Svc on all 304 and 412 responses, not just 200s. That keeps the QUIC pin alive even for long-lived, revalidated assets.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker