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
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 RTTsSteps 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:
- Omnibox.
shop.example.comends in a known top-level domain, so it is a URL;python asynciois 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. - 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 explicithttp://links, falling back to http if https fails. A host on the HSTS (HTTP Strict Transport Security) preload list, or one that sent aStrict-Transport-Securityheader earlier, is rewritten with no fallback: DevTools shows307 Internal RedirectwithNon-Authoritative-Reason: HSTS, and no packet went out. - Service worker. If the origin registered one whose scope covers the URL,
the navigation goes to its
fetchhandler first, which can answer from Cache Storage without touching the network (Service Workers and PWAs). - 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 answered304 Not Modified. The back/forward cache serves Back and Forward only, never a typed URL. - 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:
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/https://shop.test/ redirects=1 redirect=0.201654 ttfb=0.559067
https://shop.test/ redirects=0 redirect=0.000000 ttfb=0.356225The 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:
- Its own host cache - the “Clear host cache” button in
chrome://net-internals/#dnsempties it. - The hosts file.
- 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). - The recursive resolver’s cache.
- 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:
dig @1.1.1.1 +short cloudflare-ech.com HTTPS | tr ' ' '\n'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:b761 .- 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 theAlt-Svcresponse 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:
http=http/3
tls=TLSv1.3
sni=encrypted
kex=X25519MLKEM768That 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
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 $U2 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=1Read 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
browser ─▶ anycast edge ─▶ cache? ── hit ──▶ response
TLS ends here │
│ miss
▼
shield ─▶ ALB ─▶ nginx
│
▼
DB ◀─ app ◀─ uvicorn- 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.
- Cache key. The edge builds a key from host, path, query and any headers
named in
Vary, and answers a hit from the PoP. - Miss. The PoP asks a shield - one PoP per region that funnels misses - and the shield asks the origin over connections it keeps warm.
- 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:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;# 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/whoamischeme=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
103withLink: </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:
- 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. - CSS blocks rendering until the CSSOM exists. A classic
<script>also blocks the parser;deferandtype="module"run after parsing,asyncwhenever it arrives. - 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:
# 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"; done10299 bytes ttfb=0.253008 total=0.253312
13374 bytes ttfb=0.252641 total=0.352746
61549 bytes ttfb=0.253722 total=0.45428210 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:
tcpdump -i eth0 -nn -ttt "tcp and src port 8000 and greater 60" | awk '{print $1, $NF}'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 342Each 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
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']}")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=50Request 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:
(() => {
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:
# 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=22The 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:
search shop.svc.cluster.local svc.cluster.local cluster.local
options ndots:5ndots: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:
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 msTwo 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:
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 AThe 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
# 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"
donecache-control: max-age=60
x-cache-status: MISS
Hello, alice
cache-control: max-age=60
x-cache-status: HIT
Hello, aliceBob 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 withopenssl s_client, not a browser. - Strict clients. Python 3.13+ sets
VERIFY_X509_STRICTinssl.create_default_context(). This lab’s first private CA had no keyUsage extension: curl accepted it, while httpx refused withCA 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.
Related
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-Svcafter 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.
- 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
- “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-Controldrives the browser and shared caches,s-maxageor CDN rules the edge, andprivatekeeps 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-agewithoutprivate, or a cache-everything rule) and the cookie was not in the cache key. Sendprivateorno-store, then purge. Requests withAuthorizationare protected by default; cookies are not.
- Follow-up: “A user saw someone else’s dashboard. Which layer, which
directive?” - a shared cache, CDN or proxy: the response was cacheable
(
- “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-Timingfrom edge and app, the CDN’s cache status, and a syntheticcurl -wfrom the same region. At the same RTT,requestStarttoresponseStartof 45 ms versus 8.6 s puts the time behind the edge.
- Follow-up: “How do you attribute it?” - Navigation Timing per phase,
- “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 schemehttpand 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.