Part 1 · 12 chapters · ~75 min

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.

11

What a URL is, and what the browser does with it

the question

"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.

worked numbers
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.
what the browser process does before fetching
  1. 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.
  2. 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.
  3. Checks Safe Browsing. A hashed lookup against known-bad URLs. This is why a malicious page gets a red interstitial before it renders.
  4. 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.
  5. 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.
12

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.

the resolution path, outermost cache first
  1. Browser cache. Chrome keeps recent answers for about a minute. A hit is free.
  2. OS resolver cache. The system's stub resolver, with its own TTL-respecting cache.
  3. 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.
  4. 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.
worked numbers
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.
what changes the cost
  1. 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.
  2. 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.
  3. CNAME chains. A CDN-fronted host often resolves through two or three aliases. Each one is a lookup unless cached. Flatten where you can.
the lens
The Network panel's timing tab shows a "DNS Lookup" segment per request. On a repeat visit it should be zero for your own origin. If it is not, something is defeating the cache: a tiny TTL, a CNAME chain, or a resolver that is not keeping answers.
13

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.

worked numbers
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.
what this forces
  1. 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.
  2. 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.
  3. 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.
  4. TCP Fast Open and TLS 1.3 0-RTT. Send data in the handshake on a repeat visit. Supported unevenly; QUIC makes it standard.
what the browser hides
You never see sockets. The network process keeps a pool per origin, reuses connections, races IPv4 and IPv6, and limits concurrency. The rule "six connections per host" was a browser policy, not a protocol limit, and HTTP/2 made it obsolete.
14

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.

the handshake, in one round trip
  1. 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.
  2. 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.
  3. 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.
worked numbers
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.
the cost you can control
Serve the full chain. Staple OCSP. Prefer ECDSA certificates, which are smaller and faster to verify. Enable TLS 1.3 and session resumption. Each of these removes either bytes or a round trip from a path that runs before any HTML exists.
BEFORE THE FIRST BYTE
DNS, TCP, TLS, then HTTP
swipe the figure sideways, or tap expand for full screen
1/6
DNS
The browser needs an IP address for bank.example. It asks the OS resolver, which asks the configured DNS server, which may recurse to the authoritative server. One round trip if cached nearby, several if not.
15

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.1HTTP/2HTTP/3
TransportTCPTCPQUIC over UDP
Requests per connectionOne at a time (pipelining is dead)Many, as interleaved frames on streamsMany, as independent QUIC streams
Browser connections per origin611
HeadersPlain text, repeated every requestHPACK: compressed, with a shared tableQPACK: same idea, reordering-safe
Head-of-line blockingApplication layer: request waits for the one aheadTransport layer: a lost packet stalls all streamsPer stream only
Handshake costTCP + TLSTCP + TLSOne round trip combined, 0 on resume
PrioritisationNone; the browser orders requestsDependency tree (badly implemented); replaced by Extensible PrioritiesExtensible Priorities
Server pushNoYes, and removed from Chrome in 2022Spec'd, unused; 103 Early Hints instead
Connection migrationNoNoYes: survives a Wi-Fi to cellular switch
what changes in how you build
  1. 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.
  2. 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.
  3. Prioritise explicitly. fetchpriority="high" on the LCP image and low on below-the-fold work maps straight to Extensible Priorities. Part 12.
  4. 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.
HTTP/1.1, 2, 3
how the requests share the wire
swipe the figure sideways, or tap expand for full screen
1/5
HTTP/1.1
HTTP/1.1: one request at a time per connection, so the browser opens six connections per origin and queues the rest. Request 7 waits for one of the first six to finish. Head-of-line blocking at the application layer.
16

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.

the rules, as Chrome applies them
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
worked numbers
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.
17

The HTTP cache, properly

the question

"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.

the directives, with what they actually do
  1. max-age=N. Fresh for N seconds. Within that, serve from cache with no request. The single most valuable header on a static asset.
  2. immutable. Even on a reload, do not revalidate while fresh. Pair with fingerprinted filenames.
  3. no-cache. Store it, but revalidate before every use. Right for HTML. Wrong for assets.
  4. 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.
  5. private / public. Whether a shared cache (CDN, proxy) may store it. Browsers ignore the distinction; CDNs do not.
  6. 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.
  7. 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.
  8. Vary. Which request headers are part of the cache key. Vary: Accept-Encoding is normal. Vary: Cookie or Vary: User-Agent fragments the cache into near-uselessness.
the rules nobody writes down
  1. 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.
  2. 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.
  3. 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.
  4. 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 CACHE DECISION
what happens before a request leaves
swipe the figure sideways, or tap expand for full screen
1/6
memory cache
A request for /app.css. First stop is the memory cache: resources already fetched for this page and held in RAM. A hit here is nearly free and is why the same image used twice is fetched once.
18

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.

the caches, in lookup order
  1. 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.
  2. 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.
  3. HTTP cache. On disk, shared across tabs and sessions, partitioned by top-level site.
  4. 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.
  5. Push cache. HTTP/2 pushed resources, per connection, short-lived. Mostly historical now.
  6. bfcache. Not a resource cache at all: the whole page, DOM and JS heap, frozen in memory for back and forward. Part 10.
the DevTools column that tells you which
The Network panel's Size column reads "(memory cache)", "(disk cache)", "(prefetch cache)" or "(ServiceWorker)". If a repeat visit shows real sizes on your fingerprinted assets, your cache headers are wrong. If it shows "(disk cache)" on your HTML, you have made a different mistake: the HTML will not pick up deploys until it expires.
19

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.

what the scanner does and does not see
  1. Sees: <script src>, <link rel=stylesheet>, <img src> and srcset, <link rel=preload>, <video poster>, and <iframe src> in the raw bytes, even past a blocking script.
  2. Does not see: anything inside CSS (fonts, background images), anything JavaScript inserts, import(), fetch calls, and images set via style attributes.
  3. 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.
the hints, and which problem each solves
  1. 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 as and, for fonts, crossorigin, or the preload and the real fetch will not match and you pay twice.
  2. preconnect. "Open a connection to this origin now." For third-party origins on the critical path.
  3. dns-prefetch. "Resolve this name now." The cheaper cousin, for origins you might use.
  4. prefetch. "Fetch this at low priority for the next page." For the next route's chunk.
  5. modulepreload. Preload for ES modules, which also parses and compiles them.
  6. fetchpriority. Not a hint to fetch, a hint about order: raise the LCP image, lower the carousel.
the mistake that costs the most
Preloading everything. Each preload competes with the document's own critical resources for bandwidth and early connection windows. Preload the one or two things the scanner cannot see that gate your LCP, and nothing else. DevTools warns when a preloaded resource goes unused for a few seconds; treat the warning as a bug.
THE PRELOAD SCANNER
fetching ahead of the parser
swipe the figure sideways, or tap expand for full screen
1/6
html arrives
The HTML arrives. The main parser begins building the DOM from the top.
20

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.

worked numbers
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.
where it goes wrong
  1. 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.
  2. 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.
  3. 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.
  4. 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.
21

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.

what the edge does for you
  1. 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.
  2. TLS termination. The handshake happens with the edge, which is close. The edge's connection to your origin is long-lived and already warm.
  3. 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.
  4. 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.
  5. 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".
worked numbers
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.
the lens
Response headers from a CDN usually include a cache status: 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.
22

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.

the segments of one request's bar, left to right
  1. 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.
  2. Stalled. Same causes, counted differently; also proxy negotiation.
  3. DNS Lookup. Should be zero on repeat visits and on any origin you preconnected.
  4. Initial connection. TCP handshake. Zero on a reused connection.
  5. SSL. TLS handshake. Same.
  6. Request sent. Microseconds.
  7. Waiting (TTFB). Server think time plus one network RTT. The only segment the backend owns.
  8. Content Download. Bandwidth and size. Long here means bytes; everything before it means latency.
go to the lab
  1. Open the DevTools companion app, route /network/cold-vs-warm, with the Network panel open and "Disable cache" unchecked.
  2. Hard reload, then click the document request and read the Timing tab. Note DNS, connection and SSL.
  3. Navigate away and back. The same three segments should read 0 ms and the assets should say "(disk cache)" or "(memory cache)".
  4. Route /network/third-party adds a font from a second origin. Find the request whose bar has a full handshake, then add the preconnect the page offers and reload. The handshake moves earlier and off the critical path.
  5. Route /network/no-cache-everything sets no-cache on assets. Reload twice and count the 304s. That is the cost of the directive.
what you are looking for
On a first visit, a short chain of handshakes followed by parallel downloads. On a repeat visit, almost nothing but the document's TTFB. Any handshake on a repeat visit, any 304 on a fingerprinted asset, any queueing on a critical resource is a header or a hint you can fix in one line.