Backend / Networking / 18_what_happens_when_you_enter_a_url.md

What happens when you enter a URL

Updated 4 interview angles 24 min read source
On this page11
  1. The walk at a glance
  2. Before a packet leaves the machine
  3. DNS, from the browser’s side
  4. Connection setup: where the round trips go
  5. The server side of the round trip
  6. From bytes to pixels
  7. The second visit
  8. Where it breaks in production
  9. The trade not taken
  10. Related
  11. Interview angle

What happens when you enter a URL

Article.

“You type a URL into the browser and press Enter - what happens next?” is the open-ended question that shows whether you can follow one request through every layer you will ever debug. Listing the acronyms - DNS, TCP, TLS, HTTP - is the weak answer. The strong one prices each step: nearly all the time before the first byte is round trips (RTT: a packet out, an answer back), most vanish on a repeat visit, and each one is a separate place where production breaks.

The numbers here come from two sources: a real Chrome 154 against real CDNs, and a three-container lab in which every round trip is exactly 100 ms. Each layer has its own note; this one is the walk, and it links out as it passes them.

The walk at a glance

text
Enter
 ├─ 1  omnibox: a URL or a search?         0 RTT
 ├─ 2  HSTS / https upgrade                0 RTT
 ├─ 3  prerender, service worker, cache    0 RTT on a hit
 ├─ 4  open connection to this origin?     yes: skip 5-7
 ├─ 5  DNS: A + AAAA + HTTPS records       0 to 300+ ms
 ├─ 6  TCP handshake                       1 RTT
 ├─ 7  TLS 1.3 handshake                   1 RTT
 │     (HTTP/3: QUIC does 6 and 7 in one RTT)
 ├─ 8  request via edge, LB, proxy, app    1 RTT + server
 └─ 9  stream HTML, parse, fetch, paint    more RTTs

Steps 1-4 happen inside the browser and cost nothing when they hit. Steps 5-8 are where a first visit spends its time. Step 4 is why the second visit is fast.

Before a packet leaves the machine

The browser tries hard not to use the network at all. In order:

  1. Omnibox. shop.example.com ends in a known top-level domain, so it is a URL; python asyncio is a search query. While you typed, Chrome’s predictor may already have acted: as of 2026-10 its documentation gives >30% confidence as the threshold to preconnect and >50% to prerender (chrome://predictors). A prerendered page activates instantly; everything below already happened. Pages ask for the same on their own links with the Speculation Rules API.
  2. Scheme and HSTS. A URL typed without a scheme is tried as https:// first (Chrome 90+), and HTTPS-Upgrades (Chrome 115+) do the same for explicit http:// links, falling back to http if https fails. A host on the HSTS (HTTP Strict Transport Security) preload list, or one that sent a Strict-Transport-Security header earlier, is rewritten with no fallback: DevTools shows 307 Internal Redirect with Non-Authoritative-Reason: HSTS, and no packet went out.
  3. Service worker. If the origin registered one whose scope covers the URL, the navigation goes to its fetch handler first, which can answer from Cache Storage without touching the network (Service Workers and PWAs).
  4. HTTP cache. A fresh cached document is used as is. HTML is usually sent no-cache, so the realistic best case is a conditional request answered 304 Not Modified. The back/forward cache serves Back and Forward only, never a typed URL.
  5. Connection pool. An open HTTP/2 or HTTP/3 connection to the same origin is reused before DNS is even consulted - if neither side has closed it yet.

Google announced in October 2025 that Chrome 154 turns “Always Use Secure Connections” on by default for public sites, so an http-only site gets a warning before the fallback. As of 2026-10, check the setting rather than assume it.

What a redirect costs, and what HSTS saves

The lab behind every 100 ms figure in this note: curl 8.14 (Debian trixie, built with HTTP/3) in one container; nginx 1.31 terminating TLS 1.3 with HTTP/2 and HTTP/3 in a second; uvicorn 0.54 running a raw ASGI app that spends 50 ms on each page in a third. tc qdisc add dev eth0 root netem delay 100ms on the client delays every packet it sends, so one round trip is 100 ms. In the output, ttfb is time to first byte, in seconds.

curl keeps an HSTS cache the same way a browser does, so it can show the difference:

bash
R='%{url_effective} redirects=%{num_redirects}'
R="$R redirect=%{time_redirect} ttfb=%{time_starttransfer}\n"
# 1. the server's own redirect: port 80 answers 301 -> https
curl -sL -o /dev/null --cacert ca.pem -w "$R" http://shop.test/
# 2. learn HSTS from one https response, then type http:// again
curl -s -o /dev/null --cacert ca.pem --hsts hsts.txt https://shop.test/
curl -s -o /dev/null --cacert ca.pem --hsts hsts.txt -w "$R" http://shop.test/
text
https://shop.test/ redirects=1 redirect=0.201654 ttfb=0.559067
https://shop.test/ redirects=0 redirect=0.000000 ttfb=0.356225

The 301 cost 200 ms - a TCP handshake to port 80 plus one request, only to be sent elsewhere. With the HSTS entry the client rewrote the URL itself and went straight to https. The preload list extends that to the very first visit, the one a network attacker could otherwise downgrade before any header was seen.

DNS, from the browser’s side

Who gets asked, in order

A browser stops at the first layer that answers:

  1. Its own host cache - the “Clear host cache” button in chrome://net-internals/#dns empties it.
  2. The hosts file.
  3. A resolver: DNS over HTTPS, or DoH (Chrome’s default “automatic” mode upgrades when the configured resolver supports it), Chrome’s built-in DNS client, or the OS through getaddrinfo, which keeps its own cache (Windows DNS Client, systemd-resolved, mDNSResponder).
  4. The recursive resolver’s cache.
  5. Only on a miss there: root, TLD and authoritative servers - the part DNS covers.

Three records, not one

A current browser sends A, AAAA and HTTPS (type 65, RFC 9460) queries in parallel. The third is the one most answers to this question miss:

bash
dig @1.1.1.1 +short cloudflare-ech.com HTTPS | tr ' ' '\n'
text
1
.
alpn="h3,h2"
ipv4hint=104.18.10.118,104.18.11.118
ech=AEX+DQBB+wAgACAapGNUB+r5nt7b1RH3tKzfdHiCSWwS4ih5lmKb5z+XQwAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA=
ipv6hint=2606:4700::6812:a76,2606:4700::6812:b76
  • 1 . - priority 1 (a service record, not an alias) served at this same name.
  • alpn="h3,h2" - the protocols the server speaks (ALPN identifiers): HTTP/3 and HTTP/2. The browser may open QUIC on the very first visit instead of learning it from the Alt-Svc response header after a first visit over TCP.
  • ipv4hint / ipv6hint - addresses to start connecting to before the A and AAAA answers land.
  • ech= - the public key for Encrypted Client Hello (RFC 9849, March 2026), which encrypts the hostname the TLS handshake would otherwise send in clear text (the SNI field).

Chrome fetches this record only through DoH or its built-in resolver; it cannot ask getaddrinfo for it. A Chrome that falls back to the OS resolver silently loses first-visit HTTP/3 and ECH - no error, just slower and less private. Safari has asked for HTTPS records through the OS since iOS 14 and macOS 11.

Racing IPv6 against IPv4

With both A and AAAA answers, the browser races them - Happy Eyeballs v2, RFC 8305: wait up to 50 ms for AAAA once A has arrived, try IPv6 first, start IPv4 250 ms later if IPv6 has not connected, keep whichever wins. A broken IPv6 path costs about a quarter of a second instead of a connect timeout. Version 3, still an IETF draft as of 2026-10, folds HTTPS records and QUIC into the same race.

What a lookup costs

Measured with dig @1.1.1.1: 72 ms and 344 ms for two rarely visited names the resolver had to fetch from their authoritative servers, then 16 ms for each a second later - just the round trip to the resolver. A hit in the browser’s own cache costs nothing, which is why a repeat visit skips this step.

Connection setup: where the round trips go

The handshakes

TCP takes one round trip (SYN, SYN-ACK) before the client may send data. TLS 1.3 takes one more: the ClientHello carries the hostname (SNI, or hidden inside ECH), the ALPN offer (h2, http/1.1) and a key share; the server returns its key share, certificate chain and Finished in one flight, and the client sends the HTTP request right behind its own Finished. QUIC merges both into a single handshake over UDP, with TLS 1.3 built in.

As of 2026-10 that key share is the hybrid post-quantum X25519MLKEM768 by default in Chrome (since 131), Firefox (since 132) and Apple’s OS 26 releases. It adds about a kilobyte, which pushes the ClientHello into two packets - and Chrome’s rollout exposed middleboxes that assumed one. A fresh Chrome 154 profile reading https://cloudflare-ech.com/cdn-cgi/trace - the host whose record is above - reports all of it on the first visit:

text
http=http/3
tls=TLSv1.3
sni=encrypted
kex=X25519MLKEM768

That is HTTP/3 straight away (from the record’s alpn), the hostname hidden by ECH (from its ech), and the hybrid key exchange.

The client then checks the chain up to a trusted root, the hostname against the certificate’s Subject Alternative Name list, and the validity dates.

Revocation is no longer a live OCSP query to the CA, which Mozilla measured blocking the handshake for 100 ms at the median. Chrome ships CRLSets and Firefox (137+) CRLite - revocation data pushed to the browser ahead of time - and Firefox 142 stopped checking OCSP for DV certificates. Let’s Encrypt shut its OCSP responders down on 2025-08-06, so OCSP stapling fades with OCSP.

Depth on certificates: TLS, HTTPS, Certificates.

Measured at 100 ms RTT

bash
W='%{http_version} dns=%{time_namelookup} tcp=%{time_connect}'
W="$W tls=%{time_appconnect} ttfb=%{time_starttransfer} new=%{num_connects}\n"
U=https://shop.test/
# two URLs in one call: the second transfer reuses the first connection
curl -s --cacert ca.pem -w "$W" -o /dev/null -o /dev/null $U $U
curl -s --cacert ca.pem -w "$W" -o /dev/null -o /dev/null --http3-only $U $U
curl -s --cacert ca.pem -w "$W" -o /dev/null --tls-max 1.2 $U
text
2 dns=0.000859 tcp=0.101106 tls=0.205901 ttfb=0.357761 new=1
2 dns=0.000000 tcp=0.000000 tls=0.000000 ttfb=0.151419 new=0
3 dns=0.000803 tcp=0.106251 tls=0.106847 ttfb=0.259802 new=1
3 dns=0.000000 tcp=0.000000 tls=0.000000 ttfb=0.151385 new=0
2 dns=0.000924 tcp=0.101238 tls=0.306677 ttfb=0.458860 new=1

Read it as round trips. HTTP/2 cold: TCP done at 1 RTT, TLS at 2, first byte at 3 plus the app’s 50 ms. The reused connection goes straight to the request. HTTP/3 finishes its single handshake at 1 RTT - curl reports tcp and tls together - and TLS 1.2 needs a second handshake round trip:

Path Before the first byte Lab TTFB
TCP + TLS 1.2 DNS + 4 RTT 459 ms
TCP + TLS 1.3 DNS + 3 RTT 358 ms
HTTP/3 (QUIC) DNS + 2 RTT 260 ms
QUIC 0-RTT resume 1 RTT not measured
Reused connection 1 RTT 151 ms
http://, then 301 add 2 RTT 559 ms

Every lab TTFB includes the app’s 50 ms, and lab DNS is local, so add a real lookup to every cold row. The RTT that matters is to wherever TLS ends - a CDN edge 10-50 ms away in the browser runs below, not your origin.

0-RTT, and HTTP/3 on a first visit

0-RTT. A client resuming a TLS 1.3 or QUIC session can put the request in its very first flight. Over QUIC the first byte then arrives after 1 RTT; over TCP after 2, because TCP’s own handshake still runs first. The response always costs a round trip, so 0-RTT never means zero.

Early data can be captured and replayed, so RFC 8470 lets clients put only safe requests (GET, HEAD) in it, not merely idempotent ones: a replayed PUT or DELETE still changes state. A server unsure about a replay refuses it: an intermediary marks the request Early-Data: 1, the origin answers 425 Too Early, and the client retries after the handshake completes.

HTTP/3 on a first visit needs the HTTPS DNS record with h3 in its ALPN, or a remembered Alt-Svc header from an earlier response. Without either, the first connection is TCP and QUIC starts with the next one - the browser runs below show exactly that. Chrome runs QUIC alongside a TCP attempt and marks it broken for that network when it fails, so a network that blocks UDP 443 costs a fallback, not an outage.

Coalescing, and 421

Subresources on another hostname - static.example.com for www.example.com - can ride the existing HTTP/2 or HTTP/3 connection when the certificate covers both names; Chrome also requires the second name to resolve to the IP already connected. If the server behind that connection cannot serve the other host, it answers 421 Misdirected Request and the browser retries on a new connection.

That is how one wildcard certificate on two backends that share IPs produces sporadic 421s.

The server side of the round trip

text
browser ─▶ anycast edge ─▶ cache? ── hit ──▶ response
           TLS ends here     │
                             │ miss
                             ▼
                          shield ─▶ ALB ─▶ nginx
                                             │
                                             ▼
                            DB ◀─ app ◀─ uvicorn
  1. Edge. The IP from DNS is anycast - announced from every point of presence (PoP) - so routing delivers your SYN to a nearby one. TLS ends there, which is why a handshake round trip costs 10-50 ms against a CDN instead of 100+ ms to an origin on another continent.
  2. Cache key. The edge builds a key from host, path, query and any headers named in Vary, and answers a hit from the PoP.
  3. Miss. The PoP asks a shield - one PoP per region that funnels misses - and the shield asks the origin over connections it keeps warm.
  4. Origin. A load balancer (an AWS ALB keeps idle connections 60 s by default), nginx, uvicorn, your ASGI app, the database. Every hop between two machines is its own TCP connection with its own keep-alive: see HTTPS Handshake, mTLS, Keepalive, and the Reverse-Proxy Edge, CDN (Content Delivery Network) and Load Balancers.

What the app sees when TLS ends upstream

The app receives plain HTTP from a proxy, and everything it knows about the real client arrives in headers that proxy added:

nginx
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
bash
# the same app twice: uvicorn defaults, then --forwarded-allow-ips '*'
curl -s --cacert ca.pem https://shop.test/whoami
curl -s --cacert ca.pem https://shop.test/trusted/whoami
text
scheme=http client=172.25.0.3  (headers: xff=172.25.0.4 xfp=https)
scheme=https client=172.25.0.4  (headers: xff=172.25.0.4 xfp=https)

uvicorn applies X-Forwarded-* only from addresses in --forwarded-allow-ips, which defaults to 127.0.0.1,::1 (uvicorn 0.54). nginx runs in another container, so by default the app believes it got plain http from nginx’s IP.

The consequences are concrete: url_for builds http:// links, a trailing-slash redirect sends the browser back to http, and a per-IP rate limit throttles the proxy instead of the client. Set the flag to the proxy’s addresses; '*' is only safe when nothing but the proxy can reach the app.

Riding the same round trip

  • 103 Early Hints. While the app is still working, the server or CDN can send 103 with Link: </app.css>; rel=preload, so the browser fetches critical CSS during server think time. Chrome 103+ honours preload and preconnect, Firefox preconnect from 120 and preload from 123, Safari preconnect only - and only over HTTP/2 or later.
  • Server-Timing. The app reports its own duration in a header the browser exposes to real-user monitoring (RUM) scripts; the lab app sends server-timing: app;dur=50. That header is what separates “the network was slow” from “we were slow”.

Where the response can be cached

Layer Controlled by Shared
Service worker your code, Cache Storage no
Browser cache Cache-Control, ETag no
CDN edge, shield s-maxage, CDN rules yes
Reverse proxy Cache-Control, proxy_cache yes
App caches your code, Redis yes

private keeps a response out of every shared row; no-store keeps it out of all of them. CDN rules can override origin headers in either direction, which is how the leak in the production section happens. Directive semantics: HTTP semantics and caching.

From bytes to pixels

The response streams, and the browser starts work on the first bytes:

  1. The HTML parser builds the DOM as bytes arrive, and the preload scanner reads ahead for <link>, <script> and <img>, so those requests start before the parser reaches them.
  2. CSS blocks rendering until the CSSOM exists. A classic <script> also blocks the parser; defer and type="module" run after parsing, async whenever it arrives.
  3. Style, layout, paint and composite turn the result into pixels; LCP is when the largest element paints. The mechanics are in Rendering Pipeline Deep Dive and The Critical Rendering Path and the Rendering Pipeline.

Each subresource on a new origin repeats steps 5-8 of the walk, which is what preconnect and coalescing exist to avoid (Resource Hints — preload, prefetch, preconnect, dns-prefetch, modulepreload).

The first-flight budget

How much HTML arrives in the first round trip after the request is set by TCP’s initial congestion window: 10 segments (RFC 6928), about 14 KB when every segment is full. Beyond that, slow start doubles the window each round trip:

bash
# plain HTTP straight to uvicorn - no TLS, no proxy, only TCP - at 100 ms RTT
S='%{size_download} bytes ttfb=%{time_starttransfer} total=%{time_total}\n'
for kb in 10 13 60; do curl -so /dev/null -w "$S" "http://app:8000/?kb=$kb"; done
text
10299 bytes ttfb=0.253008 total=0.253312
13374 bytes ttfb=0.252641 total=0.352746
61549 bytes ttfb=0.253722 total=0.454282

10 KB arrives with the first byte. 60 KB needs two more round trips: 10 segments, then 20, then the rest. 13.4 KB is under 14 KB and still paid one extra round trip.

Why 13.4 KB missed the first flight

A capture of the server’s segments:

bash
tcpdump -i eth0 -nn -ttt "tcp and src port 8000 and greater 60" | awk '{print $1, $NF}'
text
00:00:00.051098 148
00:00:00.000126 2896
00:00:00.000018 2896
00:00:00.000011 2896
00:00:00.000010 2896
00:00:00.000014 1448
00:00:00.099990 342

Each line is the gap since the previous packet and its payload; the handshake lines are trimmed, so the first 51 ms is the app’s sleep. uvicorn wrote the 148-byte header block as its own segment, spending one of the ten; the body filled the other nine (the capture merges pairs into 2896), and the last 342 bytes waited a full round trip for an ACK. The budget is counted in segments, not bytes. QUIC’s initial window is about the same (RFC 9002).

The second visit

Kept Skips Until
Host cache entry DNS the record’s TTL
Open connection DNS, TCP, TLS an idle timeout closes it
TLS session ticket cert exchange; 1 RTT with 0-RTT ticket lifetime
Alt-Svc entry TCP: next connection is h3 its ma= seconds
HSTS entry the http hop max-age
Cached response the request its freshness lifetime

Resumption alone does not save a round trip in TLS 1.3 - a resumed handshake is still 1 RTT. It saves the certificate exchange and its verification. Only 0-RTT moves the request into the first flight.

Connection reuse, seen from httpx

python
import time

import httpx

STEPS = ("connect_tcp", "start_tls", "send_request_headers", "receive_response_headers")


def trace(event: str, info: dict) -> None:
    # httpx passes httpcore's "<step>.started" / "<step>.complete" events here
    if event.endswith(".complete") and event.split(".")[-2] in STEPS:
        print(f"  {(time.perf_counter() - t0) * 1000:6.1f} ms  {event}")


with httpx.Client(http2=True, verify="ca.pem") as client:
    for n in (1, 2):
        print(f"request {n}")
        t0 = time.perf_counter()
        r = client.get("https://shop.test/", extensions={"trace": trace})
        print(f"  {r.http_version}, server says: {r.headers['server-timing']}")
text
request 1
   102.5 ms  connection.connect_tcp.complete
   206.4 ms  connection.start_tls.complete
   207.0 ms  http2.send_request_headers.complete
   361.0 ms  http2.receive_response_headers.complete
  HTTP/2, server says: app;dur=50
request 2
     0.3 ms  http2.send_request_headers.complete
   152.5 ms  http2.receive_response_headers.complete
  HTTP/2, server says: app;dur=50

Request 1 paid TCP and TLS before sending; request 2 sent at once. The server spent 50 ms each time - the rest is network, which only Server-Timing reveals.

The same split from a real browser

Navigation Timing gives every phase of the page load to JavaScript, so this works in the DevTools console and in a RUM beacon:

javascript
(() => {
  const n = performance.getEntriesByType("navigation")[0];
  const ms = (a, b) => Math.round(n[b] - n[a]);
  const tlsAt = n.secureConnectionStart ? "secureConnectionStart" : "connectEnd";
  return {
    protocol: n.nextHopProtocol,
    dns: ms("domainLookupStart", "domainLookupEnd"),
    tcp: ms("connectStart", tlsAt),
    tls: ms(tlsAt, "connectEnd"),
    request: ms("requestStart", "responseStart"),
    ttfb: Math.round(n.responseStart),
  };
})()

Run in a fresh headless Chrome 154 profile, one line per navigation:

text
# www.fastly.com - no HTTPS record, Alt-Svc only
first visit   protocol=h2 dns=9 tcp=44 tls=47 request=45 ttfb=146
second visit  protocol=h3 dns=0 tcp=0 tls=0 request=51 ttfb=80
# www.cloudflare.com - HTTPS record with alpn="h3,h2"
first visit   protocol=h3 dns=7 tcp=1 tls=36 request=19 ttfb=65
second visit  protocol=h3 dns=0 tcp=0 tls=0 request=13 ttfb=22

The first fastly.com visit is the textbook shape: a 44 ms RTT, one each for TCP, TLS and the request, first byte at DNS plus three round trips. It went over h2 because nothing had told Chrome about h3 yet; the second visit is h3 (learned from Alt-Svc) on a connection that was already open, so DNS, TCP and TLS are all zero. cloudflare.com got h3 on the very first visit from its HTTPS record. For QUIC, Chrome books the whole handshake under tls.

Where it breaks in production

Every lookup takes five seconds

Kubernetes writes this into the resolv.conf of every pod on cluster DNS:

text
search shop.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

ndots:5 means any name with fewer than five dots is tried against each search domain before it is tried as written. Timing socket.getaddrinfo(name, 443) - the call requests and httpx make before connect() - in a container with that resolv.conf, while tcpdump counts the queries it sends:

text
api.stripe.com                           10 queries   5,160 ms
api.stripe.com + single-request-reopen    8 queries     123 ms
api.stripe.com. (trailing dot)            2 queries      32 ms

Two failures stack. The search list turns one name into four, each asked for A and AAAA: eight queries, six of them for names that cannot exist. The other failure is the 5,160 ms, and the two extra queries in the first row.

Why exactly five seconds

glibc sends each A and AAAA pair from one socket at the same instant; when one answer is lost, it waits out the resolver timeout - 5 s by default - and asks again. A plain lookup of pypi.org, no search list involved:

text
22:43:49.644038 172.25.0.4.41968 > 1.1.1.1.53: 51657+ A? pypi.org.
22:43:49.644130 172.25.0.4.41968 > 1.1.1.1.53: 12239+ AAAA? pypi.org.
22:43:49.657401 1.1.1.1.53 > 172.25.0.4.41968: 12239 4/0/0 AAAA
22:43:54.648566 172.25.0.4.41968 > 1.1.1.1.53: 51657+ A? pypi.org.
22:43:54.662185 1.1.1.1.53 > 172.25.0.4.41968: 51657 4/0/0 A

The AAAA answer came back in 13 ms, the A answer never did, and glibc re-asked at exactly +5 s. Here the loss happened inside Docker Desktop’s NAT. In Kubernetes the classic cause is a race in conntrack, the kernel table NAT relies on, when two UDP packets from one new source port cross kube-proxy’s DNAT together. The signature is the same - lookups that take the resolver timeout plus a little - and so are the fixes.

Use a trailing dot or a lower ndots in the pod’s dnsConfig for external names, options single-request-reopen (glibc only; Alpine’s musl ignores it), or NodeLocal DNSCache so queries never cross the NAT.

Someone else’s dashboard

bash
# a per-user page sent with Cache-Control: max-age=60 - and no "private"
KEEP='^(cache-control|x-cache-status)|^Hello'
for u in alice bob; do
  curl -si --cacert ca.pem -b "user=$u" https://shop.test/dashboard | grep -iE "$KEEP"
done
text
cache-control: max-age=60
x-cache-status: MISS
Hello, alice
cache-control: max-age=60
x-cache-status: HIT
Hello, alice

Bob got Alice’s page. nginx’s cache key is scheme, host and URI; the cookie that made the page Alice’s is not in it, and nothing in the response said the page was per-user. With Cache-Control: private, max-age=60 both requests are a MISS and each user sees their own page.

Vary: Cookie would also stop the leak, at the price of a hit rate near zero. Shared caches already refuse to reuse responses to requests carrying Authorization (RFC 9111); cookies get no such protection.

This is not hypothetical. On 2015-12-25 a caching rule deployed during a DoS attack on the Steam store served pages generated for about 34,000 users - billing addresses, purchase history - to other people. Mark per-user HTML private or no-store, keep “cache everything” rules off authenticated paths, and alert on HITs for responses that set cookies.

Failover that waits for the TTL

Changing a DNS record moves traffic only as fast as caches expire: the old TTL at every resolver, the browser’s host cache, and every pooled connection, which never consults DNS again while it stays open. Lower the TTL a day before a planned cutover, and put failover behind an address that does not change - an anycast IP or a load balancer - rather than in DNS.

Certificates that only fail outside the browser

  • Expired. CA/Browser Forum ballot SC-081 caps public certificate lifetime at 200 days from 2026-03-15, 100 days from 2027-03-15 and 47 days from 2029-03-15. A renewal that is not automated with ACME will be missed; alert on days to expiry.
  • Missing intermediate. Chrome downloads a missing intermediate from the issuer URL in the certificate (AIA) and Firefox ships known intermediates, so browsers work while curl, Python and most server-side clients fail with unable to get local issuer certificate. Test with openssl s_client, not a browser.
  • Strict clients. Python 3.13+ sets VERIFY_X509_STRICT in ssl.create_default_context(). This lab’s first private CA had no keyUsage extension: curl accepted it, while httpx refused with CA cert does not include key usage extension.

Large responses hang, small ones work

When a link on the path has a smaller MTU (a VPN, a tunnel) and something drops the ICMP “fragmentation needed” messages that path MTU discovery relies on, every full-size packet vanishes. The TCP handshake and small exchanges work; the first full-size packet - a big certificate chain or the HTML itself - never arrives, and the transfer stalls. ping -M do -s 1472 host on Linux finds the real path MTU; clamp the TCP MSS on the tunnel or let that ICMP through.

Redirect chains

http://example.com to https://example.com to https://www.example.com to https://www.example.com/en/ is three round trips before the HTML, two of them on new connections. curl -sL -w '%{num_redirects} %{time_redirect}\n' shows the cost; link to the final URL everywhere, and preload HSTS.

502s between the load balancer and the app

An AWS ALB keeps idle connections to targets for 60 s by default; uvicorn closes idle keep-alive connections after 5 s (--timeout-keep-alive). The ALB picks a connection uvicorn has just closed, the request hits a reset, and the user gets a 502 that never reached your logs. The app’s keep-alive must outlive the load balancer’s idle timeout - 65 s against 60.

Attributing a slow TTFB

RUM says 900 ms and the app’s own timer says 50 ms. The difference happens before the handler starts or after it returns: redirects, DNS, TCP, TLS, the trip to the edge, an edge miss and the shield, queueing at the load balancer, a cold connection from the proxy. Split it with the Navigation Timing breakdown above, Server-Timing from edge and app, the CDN’s cache status, and synthetic curl -w probes from the users’ region.

One fastly.com run in this note’s measurements took request=8627 ms with DNS, TCP and TLS all normal: the time was behind the edge, not on the wire.

The trade not taken

Caching the HTML itself at the edge. It is the biggest single win on the table: no origin trip at all, first byte one round trip to a PoP 20 ms away. Most applications with logged-in users do not take it, and they are right. The HTML is the one response that differs per user; the cache key must capture every input that changes it; purges take seconds to propagate; and one wrong rule is the dashboard leak above, at CDN scale.

The trade taken instead: content-hashed assets cached for a year, public pages with s-maxage and stale-while-revalidate, per-user HTML private, and the HTML’s latency won back by other means - a warm edge-to-origin connection, 103 Early Hints for the CSS, a small first flight. Take the other side for anonymous pages (docs, marketing, a news front page), and strip cookies at the edge before the cache key is built.

Interview angle 4

  • “Walk me from Enter to first paint.” - Browser first: the omnibox decides URL or search, HSTS rewrites http locally, and a prerender, service worker, fresh cache entry or open connection can skip the network. Otherwise DNS (A, AAAA and the HTTPS record), TCP and TLS 1.3 or one QUIC handshake, the request through edge, load balancer, proxy and app, then the HTML streams into the parser while the preload scanner fetches CSS, and paint waits for it.
    • Follow-up: “At 100 ms RTT, how many round trips before the first byte?” - three plus DNS on TCP with TLS 1.3, two on HTTP/3, one on a reused connection, four on TLS 1.2; server time on top. The trap: HTTP/3 on a first visit needs the HTTPS DNS record, or the browser only learns it from Alt-Svc after a TCP visit.
    • Follow-up: “What does the repeat visit save?” - DNS from the host cache, and if the connection is still open, everything but the request. If it closed, TLS 1.3 resumption saves certificate work but not a round trip; only 0-RTT does, and early data is replayable, so only safe requests (GET, HEAD) may ride in it - a replayed POST would run twice.
  • “Where along the path can this response be cached?” - service worker, browser HTTP cache, CDN edge and shield, a reverse-proxy cache, app caches. Cache-Control drives the browser and shared caches, s-maxage or CDN rules the edge, and private keeps a response out of every shared layer.
    • Follow-up: “A user saw someone else’s dashboard. Which layer, which directive?” - a shared cache, CDN or proxy: the response was cacheable (max-age without private, or a cache-everything rule) and the cookie was not in the cache key. Send private or no-store, then purge. Requests with Authorization are protected by default; cookies are not.
  • “RUM shows 900 ms TTFB while the app logs 50 ms. Where did the rest go?” - into everything around the handler: redirects, DNS, TCP and TLS, the trip to the edge, an edge miss to the origin, load-balancer queueing, the proxy hop.
    • Follow-up: “How do you attribute it?” - Navigation Timing per phase, Server-Timing from edge and app, the CDN’s cache status, and a synthetic curl -w from the same region. At the same RTT, requestStart to responseStart of 45 ms versus 8.6 s puts the time behind the edge.
  • “TLS terminates at the load balancer. What does your app see?” - plain HTTP from the proxy’s address. uvicorn trusts X-Forwarded-* only from 127.0.0.1 by default, so behind a proxy in another container it sees scheme http and the proxy’s IP: http redirect URLs, rate limits on the proxy.
    • Follow-up: “Why not trust X-Forwarded-For from everyone?” - any client can send it. Trust it only from proxies you run, and read the right-most address your own proxies did not add.