The Network Stack
Before any HTML exists, the browser resolves a name, opens a connection, negotiates encryption and sends a request, and each of those is a round trip the renderer cannot hide. This part is that path in order, then the caches that let it be skipped, the preload scanner that races ahead of the parser, and the hints that exist to feed it. By the end, a Network panel waterfall should read like a sentence.
What a URL is, and what the browser does with it
"I typed an address. What happens before the request is even built?"
The address bar is not a URL field. It is an omnibox that decides whether you typed a search, a hostname, a full URL or something malformed, and only then constructs a request. Then the URL itself is parsed into parts that different components own.
https://user:[email protected]:8443/accounts/42?tab=history#txn-9 scheme https → which protocol handler, whether TLS userinfo user:pw → stripped, warned about, mostly dead host bank.example → DNS, and half of the origin port 8443 → the other half of the origin path /accounts/42 → sent to the server, routing query ?tab=history → sent to the server, part of the cache key fragment #txn-9 → never sent; the page scrolls to it after load origin = scheme + host + port. everything in part 9 keys on that triple.
- Normalises. Punycode for international hostnames, percent-encoding for the path, lowercasing the host. Two URLs that look different may be the same resource and the same cache key.
- Checks HSTS. If the host is on the preload list or was seen with a Strict-Transport-Security header, an http:// URL is rewritten to https:// before any request leaves. No insecure first hop.
- Checks Safe Browsing. A hashed lookup against known-bad URLs. This is why a malicious page gets a red interstitial before it renders.
- Chooses a renderer. Site isolation decides whether an existing process can host this site or a new one is spawned. Spawning costs tens of milliseconds, so it is started speculatively while the network wakes up.
- Consults the cache. If the document is in the HTTP cache and fresh, there may be no request at all. If it is in bfcache, the whole page is restored from memory. Part 10.
DNS: resolving a name
Before a connection, the browser needs an IP. DNS is a distributed, cached, hierarchical lookup, and the cache layers are where the time goes or disappears.
- Browser cache. Chrome keeps recent answers for about a minute. A hit is free.
- OS resolver cache. The system's stub resolver, with its own TTL-respecting cache.
- Recursive resolver. Your ISP's, or 8.8.8.8, or 1.1.1.1, or whatever DoH endpoint the browser is configured for. It holds a large shared cache; a popular name is almost always warm here.
- Authoritative chain. On a miss, the resolver walks root → TLD (.example) → the authoritative server for bank.example. Three round trips, cached afterwards for the record's TTL.
what the answer carries: A IPv4 address AAAA IPv6 address (browsers race both: Happy Eyeballs) CNAME an alias, which triggers another lookup HTTPS hints: this host speaks HTTP/3, use ECH (newer) TTL how long every cache above may keep it a low TTL buys fast failover and costs a lookup per TTL per resolver.
- DNS over HTTPS. Chrome and Firefox resolve through an encrypted channel to a configured resolver. Privacy from the network path, and a slightly different latency profile because it rides on an HTTPS connection that itself had to be set up.
- dns-prefetch.
<link rel="dns-prefetch" href="//cdn.example">resolves a name now so the later connection skips step 4. Cheap, and worth it for every third-party origin on the critical path. - CNAME chains. A CDN-fronted host often resolves through two or three aliases. Each one is a lookup unless cached. Flatten where you can.
TCP: the handshake and slow start
TCP gives the browser an ordered, reliable byte stream, and charges for it in round trips. Two costs matter: the handshake before data can flow, and congestion control that keeps the early packets small.
the three-way handshake, one RTT before a single byte of HTTP: client → SYN server → SYN-ACK client → ACK (+ first data on the same flight) slow start, which is why the first response is throttled: initial congestion window 10 segments ≈ 14 KB each RTT without loss window doubles to send 200 KB ~4 RTTs after the handshake 14 KB is the budget for your HTML head and critical CSS if you want them in the first flight.
- Fewer connections. Every new TCP connection repeats the handshake and restarts slow start. HTTP/2 and 3 exist largely to stop opening six of them.
- Keep-alive. A warm connection has an open window. Idle connections are kept in a pool per origin for a few minutes precisely so the next request skips both costs.
- The 14 KB rule. Critical CSS inlined within the first ~14 KB of HTML arrives in the first congestion window. Everything after it waits a round trip.
- TCP Fast Open and TLS 1.3 0-RTT. Send data in the handshake on a repeat visit. Supported unevenly; QUIC makes it standard.
TLS 1.3: the handshake, certificates, and what you pay
Every https:// connection negotiates encryption before HTTP begins. TLS 1.3 made it one round trip and removed the weak options; the cost that remains is the round trip and the certificate check.
- ClientHello. Supported ciphers, the server name (SNI, so one IP can host many sites), ALPN (which HTTP version the client speaks), and a key share so the server can derive a session key immediately.
- ServerHello. The chosen cipher, the server's key share, the certificate chain, a signature proving the server holds the private key, and Finished. Encryption is on from here.
- Client verifies. The chain leads to a root the browser trusts, the name matches, nothing is expired or revoked, and Certificate Transparency logs have seen the cert. Then Finished, and HTTP begins on the same flight.
what the chain check involves: leaf cert bank.example, signed by intermediate intermediate signed by a root root in the browser's trust store missing intermediate on the server → works in Chrome (it fetches it), fails on some mobile revocation → OCSP stapling, or Chrome's CRLSet; a live OCSP fetch is a round trip you do not want 0-RTT resumption: a returning client sends the request inside the first flight. safe only for idempotent requests, because the flight can be replayed.
HTTP/1.1, HTTP/2, HTTP/3
Same semantics, three different ways of putting requests on a wire. The differences are about concurrency and what a lost packet costs.
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP |
| Requests per connection | One at a time (pipelining is dead) | Many, as interleaved frames on streams | Many, as independent QUIC streams |
| Browser connections per origin | 6 | 1 | 1 |
| Headers | Plain text, repeated every request | HPACK: compressed, with a shared table | QPACK: same idea, reordering-safe |
| Head-of-line blocking | Application layer: request waits for the one ahead | Transport layer: a lost packet stalls all streams | Per stream only |
| Handshake cost | TCP + TLS | TCP + TLS | One round trip combined, 0 on resume |
| Prioritisation | None; the browser orders requests | Dependency tree (badly implemented); replaced by Extensible Priorities | Extensible Priorities |
| Server push | No | Yes, and removed from Chrome in 2022 | Spec'd, unused; 103 Early Hints instead |
| Connection migration | No | No | Yes: survives a Wi-Fi to cellular switch |
- Stop sharding and spriting. Domain sharding and image sprites were HTTP/1.1 workarounds for the six-connection limit. On HTTP/2 they cost you: more handshakes, worse caching.
- Many small files are fine. Code splitting into fifty chunks is cheap on one multiplexed connection. On HTTP/1.1 it was fifty queued requests.
- Prioritise explicitly.
fetchpriority="high"on the LCP image andlowon below-the-fold work maps straight to Extensible Priorities. Part 12. - Use 103 Early Hints. The server sends preload hints before the HTML is ready, so the browser fetches CSS during server think time. This is what push was supposed to be.
Connection management: pools, limits and coalescing
The network process keeps a pool of open connections and decides which request rides on which. The rules are invisible and they shape your waterfall.
- Per-origin limits. HTTP/1.1: six connections per host, and a global cap around 256. HTTP/2 and 3: one connection per origin, with up to 100 or more concurrent streams negotiated by the server's SETTINGS.
- Idle reuse. A finished connection stays open for reuse. Chrome keeps idle sockets for about five minutes; a request to the same origin inside that window skips every handshake.
- Connection coalescing. If a.cdn.example and b.cdn.example resolve to the same IP and the certificate covers both names, HTTP/2 reuses one connection for both. Sharding across subdomains of one CDN often collapses to one socket anyway.
- Preconnect.
<link rel="preconnect" href="https://api.example">opens the socket and finishes TLS now. Use it for the two or three origins on your critical path; the browser drops unused preconnects after about ten seconds. - Request prioritisation. Before a request is sent, it is placed in a priority queue: document first, then CSS and fonts and sync scripts, then async scripts and images in the viewport, then everything else. The queue order is visible as the "Priority" column in DevTools.
a waterfall you should be able to explain: req 1 document [dns][tcp][tls][wait......][download] req 2 app.css [wait][dl] ← same connection, no handshake req 3 cdn/font [dns][tcp][tls][wait][dl] ← new origin, full cost req 4 hero.jpg [queued...][dl] ← low priority, waited req 3's handshake is what preconnect removes. req 4's queue is what fetchpriority removes.
The HTTP cache, properly
"We set no-cache on everything to be safe. Why is the site slow on repeat visits?"
Because no-cache means "store it, but ask me before using it", which turns every asset into a round trip. The cache has precise rules, and almost every performance problem on repeat visits is a misreading of them.
- max-age=N. Fresh for N seconds. Within that, serve from cache with no request. The single most valuable header on a static asset.
- immutable. Even on a reload, do not revalidate while fresh. Pair with fingerprinted filenames.
- no-cache. Store it, but revalidate before every use. Right for HTML. Wrong for assets.
- no-store. Do not write to disk at all. Right for authenticated API responses and anything with personal data. It also blocks bfcache in some cases.
- private / public. Whether a shared cache (CDN, proxy) may store it. Browsers ignore the distinction; CDNs do not.
- stale-while-revalidate=N. Serve the stale copy immediately, refresh in the background for N seconds. The best of both for content that can be slightly old.
- ETag / Last-Modified. Validators. The browser sends them back as If-None-Match / If-Modified-Since; a 304 reuses the body. A weak ETag (W/"...") allows semantic equivalence.
- Vary. Which request headers are part of the cache key.
Vary: Accept-Encodingis normal.Vary: CookieorVary: User-Agentfragments the cache into near-uselessness.
- No headers means heuristics. With Last-Modified and nothing else, Chrome assumes freshness for 10% of the age since modification. Set headers; do not rely on this.
- The cache is partitioned by top-level site. Since 2020, a resource cached while on news.example is not reused on bank.example even from the same CDN URL. Shared-CDN caching across sites is dead.
- A reload revalidates everything; a navigation does not. Pressing reload sends conditional requests for the document and its subresources unless they are immutable. Clicking a link uses the fast path.
- Responses over ~2 MB may not be cached depending on disk budget, and the whole cache is evicted LRU under pressure. Do not assume a 20 MB bundle stays warm.
The memory cache, prefetch cache and the rest
The HTTP cache is one of several. The order the browser checks them explains several behaviours that look like bugs.
- Memory cache. Per renderer, per page. Holds decoded resources already fetched for this document. Two
<img src="a.png">tags are one fetch. Dies with the page. - Service worker cache (Cache API). If a service worker controls the page, its fetch handler runs before the HTTP cache. It can answer from the Cache API, from the network, or synthesise a response. Part 8.
- HTTP cache. On disk, shared across tabs and sessions, partitioned by top-level site.
- Prefetch cache. Resources fetched by
<link rel="prefetch">for the next navigation sit here for about five minutes and are then promoted to the HTTP cache if used. - Push cache. HTTP/2 pushed resources, per connection, short-lived. Mostly historical now.
- bfcache. Not a resource cache at all: the whole page, DOM and JS heap, frozen in memory for back and forward. Part 10.
The preload scanner and resource discovery
The main HTML parser stops whenever it hits a synchronous script, because the script might call document.write and change everything after it. A page with three scripts in the head would discover its stylesheet and images one blocking step at a time. The preload scanner is the fix, and most of the "hints" exist to feed it.
- Sees:
<script src>,<link rel=stylesheet>,<img src>andsrcset,<link rel=preload>,<video poster>, and<iframe src>in the raw bytes, even past a blocking script. - Does not see: anything inside CSS (fonts, background images), anything JavaScript inserts,
import(), fetch calls, and images set viastyleattributes. - Cannot evaluate: it tokenises without building a tree, so it cannot know whether a tag is inside a
<template>or a<noscript>. It will fetch things that never render.
- preload. "Fetch this now, I will use it soon." For resources the scanner cannot see: fonts, the hero image set in CSS, a late-discovered critical script. Requires
asand, for fonts,crossorigin, or the preload and the real fetch will not match and you pay twice. - preconnect. "Open a connection to this origin now." For third-party origins on the critical path.
- dns-prefetch. "Resolve this name now." The cheaper cousin, for origins you might use.
- prefetch. "Fetch this at low priority for the next page." For the next route's chunk.
- modulepreload. Preload for ES modules, which also parses and compiles them.
- fetchpriority. Not a hint to fetch, a hint about order: raise the LCP image, lower the carousel.
Compression, encoding and transfer size
Bytes on the wire are not bytes in the file. The negotiation is automatic, the gains are large, and the failure mode is silent.
Accept-Encoding: gzip, deflate, br, zstd
gzip everywhere, ~70% smaller for text
brotli ~15-20% better than gzip on text; slower to compress, so precompress static files
zstd newer; brotli-class ratios at gzip-class speed; Chrome since 2024
a 300 KB JS bundle:
raw 300 KB
gzip ~90 KB
brotli ~75 KB ← what should cross the wire
images and video are already compressed. do not gzip them; it costs CPU and saves nothing.- The CDN is not compressing because the origin sent no Content-Type the CDN recognises as text, or because compression is off for that path. Check the Content-Encoding response header on every text asset.
- Dynamic brotli at the highest level on an origin server, adding tens of milliseconds of CPU to TTFB. Precompress static assets at build time; use a fast level for dynamic HTML.
- Transfer size versus resource size. DevTools shows both. Budgets should be set on transfer size, because that is the network cost, and checked on resource size, because that is the parse and memory cost.
- Content-Length and chunked transfer. A streamed HTML response has no length and arrives in chunks; the parser starts on the first one. Buffering the whole response server-side to compute a length throws that away.
CDNs and the edge
A CDN puts a cache near the user, terminates TLS there, and keeps a warm connection back to your origin. For most pages it is the single largest TTFB improvement available, and it changes how you should think about cache headers.
- Distance. A user in Lagos talking to an edge in Lagos has a 10 ms RTT instead of 150 ms to Virginia. Every round trip in Part 1 shrinks by the same ratio.
- TLS termination. The handshake happens with the edge, which is close. The edge's connection to your origin is long-lived and already warm.
- Caching. Static assets are served from edge disk on a hit. The origin sees a fraction of requests. Hit ratio is the metric; a misconfigured Vary or a cookie on static requests destroys it.
- HTTP/3 and compression for free. The edge speaks the newest protocols to the browser even if your origin is HTTP/1.1 behind it.
- Compute at the edge. Workers and functions that rewrite, route, personalise or serve a cached HTML shell with a dynamic fragment. The modern form of "cache the shell, fetch the data".
two cache layers, two sets of headers:
Cache-Control: public, max-age=60, s-maxage=86400
browser keeps it 60 s
the edge keeps it 1 day (s-maxage overrides for shared caches)
purge on deploy at the edge; the browser copy expires on its own.
this is how an HTML page can be fast and still pick up deploys within a minute.cf-cache-status, x-cache, age. Age tells you how long the edge has held the copy; a HIT with a large Age on your HTML means users are seeing a stale deploy.Reading a waterfall
Everything in this part shows up as a bar in the Network panel. Being able to read the bar is the skill.
- Queueing. Waiting for a connection, for a higher-priority request, or for the six-connection limit on HTTP/1.1. Long queueing on an HTTP/2 origin means priority, not limits.
- Stalled. Same causes, counted differently; also proxy negotiation.
- DNS Lookup. Should be zero on repeat visits and on any origin you preconnected.
- Initial connection. TCP handshake. Zero on a reused connection.
- SSL. TLS handshake. Same.
- Request sent. Microseconds.
- Waiting (TTFB). Server think time plus one network RTT. The only segment the backend owns.
- Content Download. Bandwidth and size. Long here means bytes; everything before it means latency.
- Open the DevTools companion app, route
/network/cold-vs-warm, with the Network panel open and "Disable cache" unchecked. - Hard reload, then click the document request and read the Timing tab. Note DNS, connection and SSL.
- Navigate away and back. The same three segments should read 0 ms and the assets should say "(disk cache)" or "(memory cache)".
- Route
/network/third-partyadds a font from a second origin. Find the request whose bar has a full handshake, then add thepreconnectthe page offers and reload. The handshake moves earlier and off the critical path. - Route
/network/no-cache-everythingsets no-cache on assets. Reload twice and count the 304s. That is the cost of the directive.