Field guide · Nº 14

The Life of a Request

A field guide to HTTP and the network beneath it

Every slow first load, stale page, and login that won't stick is explained by one protocol conversation you've probably never watched happen. This course follows a single request from the URL bar to the rendered byte — DNS, handshakes, headers, caches, and the middleboxes in between — until you can narrate the whole journey and verify any stage of it with curl and a waterfall.

Module 01 The life of a request

You type a URL, press Enter, and roughly two hundred milliseconds later a page appears. In that gap, at least three machines you did not name have spoken to each other, two cryptographic handshakes have completed, four protocol layers have wrapped and unwrapped the same few kilobytes, and a cache somewhere decided whether to answer or ask. Almost every practitioner ships software over this machinery for years without once watching it happen.

This module draws the map. It is deliberately shallow and complete: every stage named, every stage timed, every stage assigned to the module that dissects it. Two ideas from this module do most of the work later — that latency is measured in round trips rather than bandwidth, and that each layer owns exactly one guarantee, so a broken guarantee tells you which layer to suspect. Everything after this is detail on a map you already hold.

One URL, seven stages

A URL looks like a location. It is closer to a set of instructions addressed to different actors, most of which never see the whole string. Take the URL this course traces from end to end: https://app.cellarbook.com:443/cellar?sort=vintage#reds — the address of the cellar page in Cellarbook, the wine-collection product whose API is designed in Guide Nº 07.

A URL exploded into scheme, host, port, path, query, and fragment, with the actor that consumes each partSix parts, five different consumershttps://app.cellarbook.com:443/cellar?sort=vintage#redsschemepicks protocol + portport443 implied by httpsquerysent to the serverhostthe name DNS resolvespathwhat the server routes onfragmentnever leaves the browserOnly the host reaches DNS; only path and query reach your application code.
Figure 1.1 — The URL, disassembled. The scheme selects the protocol and the default port; the host is the only part DNS ever sees; the path and query are the only parts your server receives; the fragment is stripped by the browser before the request is built, which is why a # route change never touches the network and never appears in server logs.

Pressing Enter starts a fixed sequence. Resolve: turn app.cellarbook.com into an IP address. Connect: open a TCP (or QUIC) connection to that address. Secure: run the TLS handshake so the bytes are confidential and the server is who it claims. Request: send the method line and headers. Respond: the server — or a cache in front of it — produces a status line, headers, and a body. Render: the browser parses, discovers subresources, and starts the whole sequence again for each of them. The seventh stage is the one nobody counts: reuse — every subsequent request on that connection skips stages one through three entirely, which is why the second page load feels like a different product.

Note

The sequence is fixed but not always fully executed. A warm browser may have the DNS answer cached, the connection open, and the response in its own cache — in which case stages one through five are all skipped and the "request" never leaves the machine. Most of the performance work in this course is arranging for exactly that.

The layer cake

The request you write is not the thing that travels. Each layer takes the layer above as an opaque payload, wraps it in its own header, and hands it down — encapsulation. The value of knowing this is not trivia; it is triage. Each layer makes exactly one promise, so when a promise breaks you know which layer to interrogate first.

One HTTP request nested inside a TLS record, TCP segment, IP packet, and Ethernet frame, each layer labeled with its single responsibilityOne request, four wrappersEthernet framegets it to the next machine on this linkIP packetaddressing and routing, host to host — best effortTCP segmentreliability, ordering, congestion controlTLS recordconfidentiality, integrity, server identityGET /cellar · headers · bodyHTTP: meaning — what is being asked, and of whatEach layer treats everything inside it as opaque bytes. Break one promise, suspect one layer.
Figure 1.2 — The layer cake. HTTP carries meaning; TLS makes the bytes secret and tamper-evident and proves who the server is; TCP turns a lossy packet service into an ordered, reliable stream; IP moves packets between hosts with no promises at all. A symptom belongs to the layer whose promise it violates: a certificate warning is TLS, a connection reset is TCP or a middlebox, a 500 is HTTP and above.

Read the diagram as a diagnostic instrument. "The page loaded but the data was wrong" is above HTTP — application logic. "The connection dropped halfway through the download" is TCP or something along the path terminating it. "The browser says the certificate is not trusted" is TLS, and specifically its identity promise. "The name doesn't resolve at all" is beneath everything here: no address means IP never got a destination.

From your other work

Security review work trains the same instinct in a different vocabulary: a control fails at a specific boundary, and naming the boundary is most of the finding. The layer cake is that discipline applied to a request. When a vendor answers "is the data encrypted in transit?" with an unqualified yes, the honest follow-up is a layer-and-boundary question — one this course answers concretely in modules 3 and 8.

The clock is round trips

Here is the trace this entire course returns to. From an office network in San Francisco, cold — no DNS cache, no open connection, no HTTP cache — the request curl -v https://app.cellarbook.com/cellar takes about 221 ms to first rendered byte. Cellarbook is served by a CDN edge 18 ms away; the origin sits in us-east-1, 53 ms behind the edge (106 ms round trip; the client is 9 ms from the edge each way).

Annotated timeline of the 221 millisecond cellarbook cold page load, split into DNS, TCP, TLS, time to first byte, and download, each labeled with the module that explains itCold load of app.cellarbook.com/cellar — 221 ms to the last HTML byteDNSTCPTLSwaiting for the first byte0284769210221 msDNS resolution — 28 ms — recursive walk, nothing cachedmodule 02TCP handshake — 19 ms — one round trip to the edgemodule 03TLS 1.3 handshake — 22 ms — one round trip plus cryptomodule 03Time to first byte — 141 ms — edge MISS, origin fetch and think timemodules 06, 08HTML download — 11 ms — 14 KB, brotli-compressedmodules 04, 0769 ms — nearly a third of the load — elapses before one byte of HTTP is sent.Warm, with DNS cached, the connection reused, and the no-cache HTML revalidated at the origin, the same page takes about 124 ms.
Figure 1.3 — Where 221 milliseconds go. DNS 28, TCP 19, TLS 22, waiting 141, download 11. Setup consumes 69 ms before the request is even sent, and the largest single span is not transfer but waiting — the edge missed its cache and had to cross to the origin and back — 9 ms to the edge, 53 ms onward, and the same path home. Bandwidth appears nowhere in the top three costs.

Notice what is not on this chart: bandwidth. Fourteen kilobytes of compressed HTML takes 11 ms; on a connection twice as fast it would take 6 ms, buying you a 2% improvement. The costs that matter are round-trip time multiples — DNS needed a walk, TCP needed a round trip, TLS needed another — and TTFB, which is where somebody else's work hides. This is the single most useful reframing in the course: web latency is a count of round trips, plus how long the far end thought about it.

The load-bearing idea

You cannot make a round trip faster than the speed of light through fiber. You can only take fewer of them (connection reuse, HTTP/2, TLS resumption), make each one shorter (an edge 18 ms away instead of an origin 124 ms away, round trip), or avoid the request entirely (caching). Every performance technique in this course is one of those three moves.

When it misleads

An "average page load time" of 221 ms describes nobody's experience. The cold visitor pays all five spans; the returning visitor with a warm cache pays about 124 ms, still one mandatory revalidation round trip because the HTML is no-cache; the visitor on a lossy mobile network pays several retransmission timeouts that appear in no diagram. Always ask which population a latency number describes before you optimize for it.

The map you will fill in

Every later module owns a span of Figure 1.3. Module 2 dissects the 28 ms of DNS and the caches that usually make it zero. Module 3 owns the 41 ms of handshaking and the question of what the padlock actually proved. Module 4 opens the request and response that were sent across the connection. Module 5 explains why the second request still knows who you are. Module 6 attacks the 141 ms directly — that span exists only because a cache said no. Module 7 asks what changes when the same semantics ride HTTP/1.1, HTTP/2, or HTTP/3. Module 8 names the machines between you and the origin, which the timeline politely hides. Module 9 hands you the instruments and returns to this exact trace, now readable line by line.

One discipline runs through all of them, and it is worth stating before you need it. When application logs and the wire disagree, the wire wins. Logs record what your code believed happened — they are written by the same process whose assumptions may be wrong, and they are silent about everything that failed before reaching it. A request rejected by a load balancer, blocked by a CORS preflight, withheld by a cookie policy, or answered from a CDN cache leaves no trace in your origin logs at all. Absence of a log line is not evidence that nothing happened; it is evidence that nothing reached you.

From your other work

The litigation instinct is exactly right here: application logs are testimony — a witness's account, shaped by what the witness was looking for. A curl transcript or a HAR export is documentary evidence of what crossed the wire. You would never resolve a factual dispute on testimony when the document is available, and you should not debug that way either.

Worked example

A Cellarbook engineer reports that /cellar "returns 500 for some users." The origin logs show no 500s at all for the affected window. Read as testimony, this looks like a mystery. Read as layers: the 500 is being produced by something in front of the origin, so the candidates are the CDN edge and the load balancer, and the next command is curl -v from an affected network to see which hop sets the response headers. In this course's topology, the response carried x-cache: MISS and no origin request ID — the edge answered on its own, because the origin had timed out and the edge served its own error page.

Module 02 Finding the server

The first 28 ms of the Cellarbook cold load are spent on a question HTTP cannot answer: where is app.cellarbook.com? DNS answers it, and it does so through a system whose two defining properties — delegated authority and caching at every hop — explain nearly everything practitioners find surprising about it.

This module covers the record types that matter to someone shipping a web product, the exact path a lookup takes, and the arithmetic of a cutover. That last part earns its keep on the day you move a domain: the difference between an engineer who says "DNS is propagating" and one who says "the old TTL was 86400, so the last cached client moves at 09:14 tomorrow" is the difference between waiting and knowing.

Names, records, and the tree

DNS is a delegation hierarchy, not a database. The root servers do not know where app.cellarbook.com lives; they know who is responsible for .com. The .com servers do not know either; they know which nameservers are responsible for cellarbook.com. Only those authoritative nameservers hold the actual records. Every lookup is a walk down that chain of referrals, and every referral is itself a record.

Four record types cover almost all web practice. An A record maps a name to an IPv4 address, an AAAA record to IPv6 — these are the answers everything else exists to produce. A CNAME is an alias: it says "this name is really that name, start over there," which is how a product hostname points at a CDN's hostname without hard-coding the CDN's addresses. An NS record delegates a zone to a set of nameservers, forming the edges of the tree. (MX for mail and TXT for verification records round out the working set; neither affects a page load.)

The DNS delegation tree from root through .com and .net to the cellarbook zone and the CDN zone, showing the CNAME that restarts resolutionroot (.)NSNS.com.netNSNScellarbook.comcellarbook-cdn.netapp CNAME edge.cellarbook-cdn.netTTL 300edge A 151.101.65.140TTL 60restartTwo zones, two owners: Cellarbook controls the alias, the CDN controls the address.
Figure 2.1 — Delegation, and the CNAME hop. Authority is delegated downward by NS records: root knows .com, .com knows cellarbook.com, and only Cellarbook's nameservers hold the app record. That record is a CNAME, so resolution restarts in a zone Cellarbook does not control — which is exactly the point: the CDN can change its edge addresses (TTL 60) without Cellarbook touching anything.
When it misleads

A CNAME cannot coexist with other records for the same name, which is why you cannot put one at a zone apex — cellarbook.com itself must carry NS and SOA records, so it cannot also be an alias. Providers paper over this with non-standard record types (ALIAS, ANAME, or a flattened CNAME) that resolve the alias server-side and hand out an A record. Know which one you have, because the flattening happens at your provider's cadence, not the CDN's.

The resolution walk

The 28 ms in Figure 1.3 is the worst case: nothing cached anywhere. In practice a lookup is a cascade of cache checks that usually stops early. The browser keeps its own short-lived DNS cache; the operating system keeps another; the recursive resolver — your ISP's, your corporate network's, or a public one like 1.1.1.1 — keeps the big one, shared by everyone using it. Only when all three miss does anyone talk to the root.

Swimlane sequence of a cold DNS lookup from browser cache through OS cache and recursive resolver to root, TLD, and authoritative servers, with TTLs annotatedBrowser cacheOS cacheRecursive resolverRoot.com TLDAuthoritativemissapp.cellarbook.com A?who serves .com?NS referral · TTL 172800who serves cellarbook.com?NS referral · TTL 172800app.cellarbook.com A?CNAME edge.cellarbook-cdn.net · TTL 300second walk in the .net tree, mostly cachedA 151.101.65.140 · TTL 60 · 28 msEvery answer above is cached at every hop that saw it, for exactly its own TTL.
Figure 2.2 — The cold walk, 28 ms. Three caches miss, then the resolver walks the tree: root refers it to .com, .com refers it to Cellarbook's nameservers, which answer with a CNAME, restarting resolution in the CDN's zone. The referrals are cached for two days, the alias for 300 s, the final address for 60 s — so the next visitor from this network pays 0 ms, and the visitor two minutes later pays only the last hop.

Two consequences follow immediately. First, the recursive resolver is a shared cache, which means your cold lookup is often warm because a stranger on the same network asked first — and conversely, one resolver holding a stale record affects everyone behind it. Second, the record with the shortest TTL in the chain governs how quickly the answer can change. Here the CNAME is cached for 300 s but the address behind it for only 60 s, which is deliberate: the CDN wants the freedom to move traffic between edges within a minute.

Worked example

Read the chain yourself: dig +noall +answer app.cellarbook.com prints both steps with their remaining TTLs, and running it twice shows the second TTL counting down — that countdown is the resolver's cache emptying in real time. dig +trace app.cellarbook.com forces the full walk and prints each referral, which is the only way to see the tree rather than the answer.

TTLs and the propagation myth

Nothing propagates. When you change a record, the authoritative server begins answering differently immediately; every other machine keeps its cached copy until the copy's countdown reaches zero and then asks again. There is no push, no gossip, no wave sweeping the internet. "DNS is still propagating" always translates to "caches populated before my change are still serving their old answer for up to the old TTL."

That sentence contains the whole method: the wait is governed by the TTL that was in effect when the old answer was cached, not the new one. Lowering the TTL at the moment of the change accomplishes nothing for clients that already cached the old value at 86400. This is why a competent cutover is planned backwards: lower the TTL at least one old-TTL period in advance, wait, then change the record, and now your exposure is the new, short TTL.

Staircase chart showing the percentage of clients using a new IP address over twenty minutes after a record change with TTL 300, including a clamping resolverA record changed at T0, TTL 300 — clients do not move, their caches expire100%50%0%T0+5m+10m+15m+20mrecord changedall clients on the new address3% still on the old address at +20m:a resolver clamped TTL 300 up to 3600no client moves inside the first TTL window— their caches are still validPlus: established keep-alive connections never re-resolve at all until they close.
Figure 2.3 — The staircase that people call propagation. Each step is a population of caches expiring, not a change spreading. With TTL 300 most clients move within two TTL windows, but a resolver that clamps short TTLs upward holds a tail for an hour, and clients with open keep-alive connections keep using the old address regardless of DNS. Plan the cutover so both endpoints answer correctly during the overlap.
The load-bearing idea

A DNS cutover is not a switch, it is an overlap. From the moment you change the record until the last cache expires, both the old and the new destination receive real traffic — so the old one must keep working correctly for at least one old-TTL period after the change. Everything else is scheduling.

When it misleads

A 30-second TTL does not give you 30-second failover. Some resolvers clamp very short TTLs upward to protect themselves; operating systems and browsers add their own caching; and — most often missed — a client with an open HTTP keep-alive connection does not perform DNS lookups at all, so it keeps talking to the old address until that connection closes. DNS is a coarse traffic-steering tool, not a health-check-driven failover mechanism. If you need seconds, you need anycast withdrawal or a load balancer, not a lower TTL.

Anycast and geo-routing

The Cellarbook edge answers from 151.101.65.140 for every client on earth, and yet the client in San Francisco reaches a machine 18 ms away rather than one in Frankfurt. That is anycast: the same address is announced to the internet's routing system from dozens of locations, and each network simply delivers packets to whichever announcement is closest by its own routing metrics. The steering happens at the routing layer, invisibly, after DNS has finished.

The commonly stated version — "DNS returns the nearest server" — describes a different mechanism, GeoDNS, in which the authoritative nameserver inspects the resolver's location and returns different addresses to different askers. Both exist and are often combined, but they fail differently and are debugged differently.

 AnycastGeoDNS
What varies by locationNothing — the same IP everywhereThe answer itself: different A records
Where steering happensRouting (BGP), after resolutionDNS, during resolution
How you observe itSame address from two cities, different destinations (traceroute differs)Different addresses from two cities (dig differs)
Failover speedSeconds — withdraw the announcementBounded by TTL, plus resolver quirks
Common failureRouting pulls a client to a distant or overloaded siteResolver location is not the user's location

That last row is the practical trap in GeoDNS: the authoritative server sees the resolver's address, not the user's. A user in Denver on a public resolver whose nearest node is in Dallas gets a Dallas-optimized answer. Extensions exist to pass a truncated client subnet along, but support is uneven — which is one reason large CDNs prefer anycast, where being wrong about the user's location is impossible by construction.

Worked example

Two Cellarbook engineers debug "the CDN is serving Europeans from the US." One runs dig app.cellarbook.com in both offices and sees the identical address, concluding the CDN is broken. The correct next command is traceroute 151.101.65.140 from each office: the same address, two entirely different paths, terminating in two different cities. The address was never the evidence — the path is.

Module 03 Opening the secure pipe

Forty-one of the Cellarbook cold load's 221 ms are spent before a single byte of HTTP exists: 19 ms for TCP, 22 ms for TLS. That is the price of two guarantees — a reliable ordered byte stream, and a channel that is confidential, tamper-evident, and attached to a verified identity. Both guarantees are worth their cost, and both are widely misunderstood in the same direction: people assume they cover more than they do.

This module builds the handshakes packet by packet, then spends its real energy on the second question — what the padlock actually attests. That answer matters commercially as well as technically. "Encrypted in transit" is a control statement in every security questionnaire you will ever fill out, and its truth depends entirely on where the TLS session terminates, which is a topology fact, not a checkbox. The module closes with QUIC, whose central move only makes sense once TCP and TLS are both in view: it merges them.

Three packets before hello

TCP's job is to turn IP's best-effort packet delivery into a reliable, ordered byte stream. Before it can do that, both endpoints must agree that they can hear each other and settle on the sequence numbers they will use to detect loss and reordering. That agreement is the three-way handshake: the client sends SYN with its initial sequence number, the server replies SYN-ACK acknowledging it and offering its own, the client sends ACK. Three packets, one and a half round trips — but only the first full round trip blocks the application, because the client can attach data to that final ACK's flight.

So a connection to the Cellarbook edge, 18 ms away, costs about one RTT: the observed 19 ms is that RTT plus scheduling jitter. Nothing about this scales with bandwidth. A gigabit link and a DSL line 18 ms away pay the same 19 ms, which is the first concrete instance of the module 1 rule that latency is a count of round trips.

Note

This is also why connection reuse is the cheapest performance win available. Every request that rides an already-open connection pays zero for stages two and three — 41 ms saved per request, in this trace. A page with six subresources on six fresh connections would burn a quarter of a second on handshakes alone, which is precisely the problem HTTP/2 was designed to remove.

The TLS 1.3 handshake

With a byte stream established, TLS negotiates the keys. TLS 1.3 — the version you should assume in 2026 — is dramatically simpler than 1.2, and the simplification is structural rather than cosmetic. In 1.2, the client offered ciphers, the server chose one and sent its certificate, and only then did the two exchange key material: two full round trips. In 1.3 the client guesses the group the server will pick and sends its key share in the first message. The server replies with its own share, its certificate, and a Finished message — and from that point on the handshake itself is encrypted. One round trip, and the observed 22 ms is that RTT plus the elliptic-curve arithmetic on both ends.

Two other things happen in the same flight, and both matter to this course. The client sends SNI — the hostname it wants — in the clear, because the server needs to know which certificate to present before any keys exist. And the two sides negotiate the application protocol through ALPN, which is how the Cellarbook trace ends up on h2: the browser offered h2 and http/1.1, the edge picked h2, and that choice is made here, inside TLS, before HTTP speaks at all.

Three panels comparing round trips before the first HTTP byte under TLS 1.2, TLS 1.3, and QUIC, on a shared time axis at 18 milliseconds per round tripTCP + TLS 1.2TCP + TLS 1.3QUIC (HTTP/3)clientedgeclientedgeclientedgeSYNSYN-ACKACK + ClientHelloServerHello + certificatekey exchange + FinishedFinishedfirst HTTP byte — 3 RTT ≈ 54 msSYNSYN-ACKACK + ClientHello + key shareServerHello + cert + Finishedfirst HTTP byte — 2 RTT ≈ 41 msInitial: ClientHello + key shareServerHello + cert + Finishedfirst HTTP byte — 1 RTT ≈ 20 msSame 18 ms round trip in all three panels; only the number of round trips changes.
Figure 3.1 — Round trips before the first HTTP byte. TLS 1.2 needs three round trips because the certificate must arrive before key exchange begins; TLS 1.3 sends the client's key share optimistically in the first flight and gets everything back in one; QUIC folds the transport handshake into the crypto handshake so a single exchange establishes both. At an 18 ms RTT that is 54 ms, 41 ms, and 20 ms respectively — the same network, three protocol designs.

TLS 1.3 also offers 0-RTT resumption: on a repeat visit, the client can send application data alongside its very first packet, using keys derived from the previous session. It is genuinely free latency and it has a sharp edge. Those early-data bytes carry no proof of freshness, so an attacker who captured them can send them again and the server will accept them. That is harmless for GET /cellar and dangerous for anything that changes state — which is why the rule is simply: 0-RTT for idempotent requests only, enforced at the server, never left to client good behavior.

Certificates and the chain of trust

The handshake proves the server holds the private key matching the certificate it presented. That is a statement about keys, not about identity — the identity part comes from the signature chain. The leaf certificate for app.cellarbook.com is signed by an intermediate CA, which is signed by a root CA whose certificate already sits in your device's trust store because your operating system or browser vendor put it there. Chain of trust is exactly that recursion, anchored in a decision someone else made about which roots deserve trust.

The certificate chain from a root CA in the device trust store through an intermediate to the leaf certificate, with the three checks the browser performsRoot CAalready in your device trust storesignsIntermediate CAthe server must send this one toosignsapp.cellarbook.comleaf — attests control of this nameWhat the browser checks1 · Chain — every signature verifies up to aroot this device already trusts2 · Name — the requested hostname appearsin the certificate's SAN list3 · Validity — today falls inside the window,and the certificate is not revokedWhat it never checks:whether the operator is honest, is thecompany you meant, or handles data wellDomain control is the entire claim. Everything else is inference by the user.
Figure 3.2 — What the padlock actually asserts. The chain proves that a CA the device trusts vouched for control of this exact hostname, that the name matches, and that the certificate is currently valid. It says nothing about the operator's identity, intentions, or competence — which is why a phishing site can display a perfectly valid padlock, and why "has HTTPS" was never a trust signal.

Two operational notes fall out of the diagram. The server must send the intermediate certificate along with its leaf, because clients only hold roots; an incomplete chain is the classic "works in my browser, fails in curl and on Android" defect, since desktop browsers often cache intermediates from previous sites and mask the misconfiguration. And validity windows are now short — ninety days is typical — which makes renewal automation an availability concern rather than a calendar entry.

What the padlock guarantees — and does not

TLS gives you three things for one hop: confidentiality (an observer cannot read the bytes), integrity (an observer cannot alter them undetected), and server authentication (the far end holds the key for the name you asked for). Every word of that is load-bearing, and the phrase doing the most work is for one hop.

Client to CDN edge to origin, showing two separate TLS sessions and the plaintext moment inside the edge where TLS terminates"Encrypted in transit" — but between which two points?Browser203.0.113.7CDN edgeTLS terminates hereOriginus-east-1TLS 1.3 session Athe padlock covers this span onlyTLS session B — different keysor plaintext, if nobody checkedplaintext here: headers,cookies, bodies, tokensAn intermediary that terminates TLS is a party to the conversation, not a pipe.
Figure 3.3 — Two hops, two sessions, one padlock. The browser's padlock attests the client-to-edge span. At the edge the request is decrypted in full — headers, cookies, and body are readable in that machine's memory — and re-encrypted (one hopes) for the second hop under different keys. "End-to-end encrypted" is therefore false for essentially every CDN-fronted web application, and the accurate control statement names the boundary.

What still leaks even on the protected hop: the destination IP address, the SNI hostname sent in the clear, the DNS query that preceded it, and the size and timing of everything you send. An observer on the office network cannot read your Cellarbook password, but knows precisely that you visited app.cellarbook.com, when, and roughly how much you did there. What does not leak: the path, the headers, the cookies, and the bodies.

Two mechanisms patch known holes. HSTSstrict-transport-security: max-age=31536000; includeSubDomains, present in the Cellarbook response — instructs the browser to refuse plaintext HTTP for this host for a year, closing the window where a user typing the bare hostname makes one downgradeable request. Mixed content rules close the other direction: a page loaded over HTTPS that pulls a script over HTTP would let an attacker rewrite the script and own the page, so browsers simply block it.

When it misleads

curl -k, verify=False, and "accept all certificates" turn TLS into encryption without authentication — which is nearly worthless, because an attacker who can intercept traffic can also present their own certificate and read everything. The tell is that the workaround is always introduced for a real, local reason ("the staging cert is self-signed") and then ships. The fix is never to disable verification but to trust the specific CA or pin the specific certificate for the specific host that needs it.

From your other work

"Is data encrypted in transit?" is a question about boundaries, and Figure 3.3 is how to answer it defensibly: encrypted client-to-edge under TLS 1.3, decrypted at the edge under the CDN's operational control, re-encrypted edge-to-origin. That is a truthful answer that a reviewer can test. The unqualified "yes, we use HTTPS" is the one that becomes a finding when someone draws the diagram.

QUIC: the redesign

Everything above treats TCP and TLS as separate layers that each need a handshake — which is exactly the assumption QUIC discards. QUIC implements reliability, ordering, congestion control, and encryption as one protocol, in userspace, on top of UDP. UDP here is not a claim about reliability; it is a delivery substrate chosen because it is the only thing middleboxes will pass without interference. Reliability is reimplemented above it, per stream.

Three consequences matter. One round trip to first byte, because there is no separate transport handshake to complete before the crypto one starts — the third panel of Figure 3.1. Independent streams: TCP delivers one ordered byte stream, so a lost segment stalls everything behind it; QUIC's streams have separate delivery guarantees, so loss stalls only the stream that lost a packet. That distinction is the whole subject of module 7. Connection IDs: a QUIC connection is identified by an ID rather than by the four-tuple of addresses and ports, so when a phone moves from Wi-Fi to cellular and its IP address changes, the connection survives instead of being torn down and rebuilt.

Because QUIC is implemented in userspace and its transport headers are encrypted, it also escapes a structural problem TCP has: middleboxes cannot inspect or "optimize" what they cannot parse, so QUIC can evolve without waiting for every firewall vendor on earth. That is a deployment argument rather than a performance one, and it is arguably the more important of the two.

Note

Adopting HTTP/3 does not usually mean adopting QUIC yourself; it means enabling it at your CDN and letting the browser discover it via the alt-svc header. What that discovery implies for measurement — that the first connection is never h3 — is covered in module 7.

Module 04 The HTTP message itself

Everything so far was preparation. The connection is open, the keys are agreed, and now the actual conversation happens — and it is startlingly simple: a line, some headers, a blank line, and optionally a body, in each direction. HTTP's format is plain enough to read raw, and reading it raw is the point of this module.

But the format is the least interesting part. What makes HTTP work at internet scale is that its vocabulary carries promises. A method tells every intermediary whether the request may be repeated or prefetched. A status code assigns fault and tells a client whether retrying could possibly help. A dozen headers negotiate representation, framing, and identity. Caches, proxies, retry libraries, and browsers all act on those promises without understanding your application at all — which is why breaking them produces bugs in software you have never seen.

Anatomy of the message

Here is the Cellarbook cold request and its response, as they appear on the wire. The format is the same in both directions: a first line that differs, then headers as name: value pairs, then one blank line, then the body if there is one. That blank line is doing real work — it is how a parser knows the headers ended and the body began.

The raw cellarbook request and response, each line labeled with its jobOne exchange, every line accounted forGET /cellar?sort=vintage HTTP/2host: app.cellarbook.comaccept: text/html,application/xhtml+xmlaccept-encoding: gzip, brcookie: cb_session=9f31c2ae...user-agent: Mozilla/5.0 (Macintosh...)(blank line — no body)method · target · versionwhich site — virtual hosting depends on itwhat I can renderwhat I can decompressstate, bolted on — module 05HTTP/2 200content-type: text/html; charset=utf-8content-encoding: brcache-control: no-cacheetag: "8f2c-1a9d"vary: accept-encodingx-cache: MISS(blank line) <!doctype html>...version · status — fault and next actionwhat this representation ishow it was compressed — brotlicaching contract — module 06fingerprint for revalidationwhich request headers widen the cache keythe edge did not have it — the 141 ms
Figure 4.1 — The exchange, line by line. Request: method line, then headers stating which site, what representations the client accepts, what it can decompress, and who it is. Response: status line, then what this representation is, how it was encoded, and the full caching contract. Nothing here is opaque — every mystery in the later modules is visible in a message like this one.

Two details in that transcript are easy to skim past. Header names are case-insensitive, and HTTP/2 requires them lowercase on the wire — so the lowercase rendering above is literal, not a stylistic choice. And host is what makes virtual hosting possible: thousands of sites share one IP address at a CDN edge, and the only thing telling the edge which site you want is that header (plus SNI, which had to say the same thing one layer down).

Methods: safety and idempotency

A method is a promise about consequences, and infrastructure you do not own acts on that promise. A safe method — GET, HEAD, OPTIONS — asserts no meaningful side effects, which is why a browser prefetcher, a link scanner, a crawler, or an antivirus proxy will happily issue one without asking anybody. An idempotent method — the safe ones plus PUT and DELETE — asserts that repeating it leaves the same state as doing it once, which is what makes automatic retries legal.

MethodSafeIdempotentCacheableWhat it means in practice
GETyesyesyesAnything may issue it, at any time, twice
HEADyesyesyesHeaders only — cheap existence and freshness checks
PUTnoyesnoReplace at a known URL; retry freely
DELETEnoyesnoSecond call returns 404 or 204 — state is unchanged either way
POSTnonorarelyPromises nothing; retries may duplicate
PATCHnononoNot idempotent in general ("increment by 5" is not)

The consequences of getting this wrong are unusually severe because they are invisible in development. A team that implements "delete this row" behind GET has built something that a link prefetcher can trigger, that a browser may repeat on back-navigation, and that any cache may serve or replay. The classic version of this story is an admin panel whose delete links were plain GETs, emptied one weekend by a well-behaved crawler doing exactly what GET's contract permits.

Cross-reference — Guide Nº 07

POST's lack of an idempotency promise is what makes retries dangerous, and HTTP does not solve it. The application-layer patch is the idempotency key: the client mints a unique key per operation, the server stores it transactionally with the side effect and replays the stored response on resend. That mechanism is designed in detail in Guide Nº 07; here, note only where the seam is — HTTP hands you the problem, your API contract solves it.

Status codes as a decision tree

You do not need to memorize status codes. You need the first digit, which assigns fault and therefore the next action, plus roughly fifteen specific codes that carry extra instructions.

Decision tree from a response status code through its class to the correct client action, with the codes that carry exceptionsresponse arrives2xx — it worked3xx — go elsewhere4xx — your fault5xx — server failedconsume the resultfollow Locationfix the request —never repeat it as isretry with backoffif the method allows201 — read Location204 — no body, do not parse206 — partial, you asked for a range301/302 — may rewrite POST to GET307/308 — method preserved304 — cached copy still valid (m6)401 — authenticate, then retry403 — authenticated, not allowed404 / 422 — retrying cannot help429 — exception: honor Retry-After500 — unhandled; retry rarely helps502/504 — the hop behind failed503 — often has Retry-Afteralways: backoff + jitter, boundedThe retry rule, in full: retry 5xx, 429, and 408 — and only when the method is idempotentor the request carries an idempotency key. Never retry other 4xx: the request itself is the problem.
Figure 4.2 — Fault and next action, from the first digit. 2xx consume, 3xx follow, 4xx fix, 5xx retry-if-allowed. The exceptions are few and worth knowing by name: 429 and 503 carry Retry-After and invite a retry; 301 and 302 permit a client to rewrite POST into GET while 307 and 308 preserve the method; 304 is caching's answer, not an error.

The most expensive misreadings in practice are these. Retrying 4xx: a 4xx says the request as formulated is wrong, so sending it again unchanged can only produce the same answer — usually seen as a batch job hammering a 422 for hours. Not retrying 5xx: a single 503 on an idempotent PUT is exactly what a bounded backoff exists for, and giving up immediately converts a transient blip into an incident. Confusing 401 and 403: 401 means "I do not know who you are — authenticate and try again," so a client may sensibly refresh a token and retry; 403 means "I know exactly who you are and you may not," where retrying is pointless and the correct response is a permissions message, not a login prompt.

Cross-reference — Guide Nº 07

Status codes are transport-level semantics; they are not your error contract. "Which of the eleven validation rules did I violate?" is not expressible in 422 alone — that belongs in a structured error body with a stable machine-readable code, which is API design and lives in Guide Nº 07. Keep the layers separate: the status tells any intermediary what happened in HTTP terms; the body tells your client what to do about it in product terms.

The headers that matter

There are hundreds of headers and about a dozen you will use constantly. Three groups do most of the work.

Representation and negotiation. The client says what it can handle — accept: text/html,application/xhtml+xml, accept-encoding: gzip, br, accept-language: en-US — and the server states what it actually chose: content-type: text/html; charset=utf-8, content-encoding: br. This is content negotiation, and the discipline is that representation is agreed, never assumed. The Cellarbook HTML is 14 KB on the wire because brotli compressed roughly 62 KB of markup; the server chose brotli only because the client advertised it. Note the layering: content-type describes what the bytes are, content-encoding describes how they were packed, and a client that ignores the second will faithfully render compressed garbage.

Framing. The receiver must know where the body ends. Either the sender knows the length up front and sends content-length: 14204, or it does not and uses chunked transfer — each chunk prefixed by its size in hex, terminated by a zero-length chunk. Chunked is what makes streaming a response possible before you know its total size. (HTTP/2 and HTTP/3 replace this framing with their own length-delimited frames, so transfer-encoding: chunked does not appear there at all — a small example of how the message model outlives its serialization.)

Identity and control. host selects the site; authorization carries credentials; cookie and set-cookie carry state (module 5); location names the target of a redirect or the URL of a created resource; retry-after converts a 429 or 503 from a guess into an instruction; user-agent is a client's self-description, useful for diagnostics and unreliable for decisions.

Worked example

Confirm negotiation in one command. curl -sI https://app.cellarbook.com/cellar sends no accept-encoding, and the response comes back uncompressed — no content-encoding at all. Add --compressed and curl advertises gzip, br, the response gains content-encoding: br, and the transferred size drops from about 62 KB to about 14 KB. Same URL, same server, different representation — because the client asked differently. That single experiment is also the cleanest way to understand why the response carries vary: accept-encoding, which is module 6's subject.

When it misleads

A response with both content-length and transfer-encoding: chunked is malformed and must be rejected — the disagreement between two framings is the basis of request-smuggling attacks, where a front-end proxy and a back-end server parse the same bytes into different requests. If you operate an intermediary, this is not a curiosity: it is why the framing rules are enforced strictly rather than repaired charitably.

Module 05 State over a stateless protocol

The Cellarbook request in module 4 carried a cookie header, and that single line is the entire reason the page knows who you are. HTTP itself does not: every request is a fresh introduction, and the protocol's designers chose that deliberately. Statelessness is why any of the edge's servers can answer any request, why a deploy can replace every instance without logging anyone out, and why the scaling story of the web is as simple as it is.

The price is that identity must be carried, request by request, by something we bolted on. This module covers the two mechanisms that matter — cookies with server-side sessions, and bearer tokens — and treats each attribute on Set-Cookie as what it actually is: a compensating control against a specific, named attack. It closes on a bug you will recognize the next time you see it, in which a login "works" perfectly in curl and fails in the browser for reasons that live entirely in policy.

Statelessness was the right call

HTTP's rule is that each request must carry everything the server needs to process it. No conversational context, no session on the connection, no memory of the last request. That sounds like a limitation and is actually the property everything else stands on: if any request can be answered by any server, then load balancers can distribute freely, instances can be replaced mid-deploy, and horizontal scaling requires no coordination at all.

The anti-pattern that gives this away is sticky sessions — configuring the load balancer to pin each user to the instance that first served them, so that instance's in-memory session data remains reachable. It works, right up until it does not: a deploy that replaces instances logs those users out, an autoscaler scaling in logs them out, an instance failure logs them out, and traffic distribution skews toward whichever instances happen to hold long-lived users. Every one of those symptoms is the same root cause — state that belongs in a shared store was left in one process's memory, and the load balancer was asked to compensate.

The load-bearing idea

Session affinity is not a scaling technique; it is a way of hiding that your application is stateful. The fix is always to externalize the state — Redis, a database table, or a signed token the client carries — after which affinity becomes unnecessary and every failure mode above disappears at once.

Cookies: the mechanics

A cookie is a key-value pair the server writes into the browser's store, which the browser then attaches to future requests that match the cookie's scope. The write is a set-cookie response header; the read is a cookie request header the browser assembles automatically. That automatic attachment is both the mechanism's convenience and the source of its entire attack surface — the browser sends the cookie because the destination matches, not because your code decided to.

Here is the real Cellarbook session cookie, issued by POST https://api.cellarbook.com/v1/sessions:

set-cookie: cb_session=9f31c2ae...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600

Read it as a control list. Secure — never send this over plaintext HTTP, which removes network capture of the session identifier. HttpOnly — hide it from document.cookie, so a cross-site scripting flaw that can run JavaScript still cannot read and exfiltrate the session. SameSite=Lax — withhold this cookie from cross-site subrequests, which is the structural defense against cross-site request forgery. Path=/ and the absent Domain attribute — scope it to this host only, so it is not shared with sibling subdomains that may be less carefully operated. Max-Age=1209600 — fourteen days, after which the browser deletes it; without it the cookie is a session cookie that dies with the browser process, and with an over-long value you have created a credential that outlives most employment.

Swimlane of a cookie-based login: Set-Cookie on authentication, cookie attached on a same-site request, cookie withheld on a cross-site fetch under SameSite Lax, and expiry after Max-AgeBrowserapi.cellarbook.comPOST /v1/sessions {email, password}201 · set-cookie: cb_session=9f31c2ae...; Path=/;Secure; HttpOnly; SameSite=Lax; Max-Age=1209600cookie jar: cb_session storedfrom https://app.cellarbook.com — same siteGET /v1/cellar · cookie: cb_session=9f31c2ae...200 · the cellarfrom http://localhost:5173 — cross-sitefetch(..., {credentials: "include"})no cookie header — SameSite=Lax withholds it401 · unauthenticatedafter 14 days: Max-Age reached, browser deletes itGET /v1/cellar · (no cookie) → 401The browser decides whether to attach the cookie. Your code never gets a vote.
Figure 5.1 — One cookie, four moments. Issued on login with its full attribute set; attached automatically to a same-site request; withheld from a cross-site fetch even though the developer asked for credentials, because SameSite=Lax says so; and deleted when Max-Age elapses. The middle two moments are the same cookie and the same server — only the calling context differs, which is exactly what makes the failure confusing at 2 a.m.
From your other work

Read a set-cookie line the way you would read a firewall rule: every attribute is a control with a threat behind it, and every missing attribute is an accepted risk that nobody wrote down. Secure missing means network capture is in scope. HttpOnly missing means any XSS becomes account takeover rather than defacement. SameSite absent means CSRF is mitigated only by whatever the framework happens to do. That framing turns a cookie audit into a two-minute exercise with defensible findings.

Sessions versus bearer tokens

Two designs dominate, and the choice between them is not a matter of fashion. A session cookie is a claim check: the cookie carries an opaque identifier, the truth lives in a server-side store, and every request costs a lookup. A bearer token — a signed JWT, typically — is a notarized document: the claims travel inside the token, the server verifies a signature and needs no lookup at all.

Two panels comparing where state lives: an opaque session cookie backed by a session store versus a self-contained signed bearer token, with the revocation path drawn for eachSession: a claim checkBearer token: a notarized documentcookie: cb_session=9f31c2ae...App server — holds no truthSession storeone lookup per requestRevoke: delete the roweffective on the next requestauthorization: Bearer eyJhbGciOi...App server — verifies signatureno network call, no lookuppublic key onlynothing about this userRevoke: valid until expiry —unless you add a denylist (state, again)
Figure 5.2 — Where the truth lives. The session design pays a lookup on every request and gets instant revocation for free. The token design pays nothing per request and cannot revoke early without introducing a denylist that every verifier must consult — which reintroduces exactly the shared state the token was adopted to remove. The trade is lookup cost against revocation latency.

State that trade honestly and the decision usually makes itself. If the product must be able to kill a credential today — an employee offboarded, a device lost, a token leaked in a support ticket — the session design gives you that for the price of a lookup you were probably making anyway. If you are authenticating machine-to-machine traffic across services that must not share a datastore, short-lived tokens with a five-minute expiry give you an acceptable revocation window without the denylist.

When it misleads

"We are stateless now, we use JWTs" is true only until the first security incident. A denylist for revoked tokens is server state, consulted on every request, with availability requirements as strict as any session store — you have rebuilt sessions with extra cryptography and worse ergonomics. The honest version of the JWT argument is not statelessness; it is that short expiry makes the revocation gap tolerable, and the gap is a decision you should be able to name in seconds.

The SameSite trap, on the wire

Here is the bug in full, because it is the most instructive twenty minutes most engineers will ever lose. In production, Cellarbook's frontend at app.cellarbook.com and API at api.cellarbook.com share the registrable domain cellarbook.com, so requests between them are same-site and everything works. A developer runs the React frontend locally at http://localhost:5173 against the production API. Login appears to succeed — the network panel shows 201 with the set-cookie header right there, and the cookie is visible in the Application tab. Every subsequent request returns 401. The same request from curl with -b cb_session=… works perfectly.

The evidence points at exactly one thing: the cookie exists and is not being sent. localhost and cellarbook.com are different sites, so a fetch() from the local page to the API is a cross-site subrequest, and SameSite=Lax withholds cookies from precisely those. The credentials: "include" option does not override this — it is the developer's request to send credentials, not the browser's permission to.

Grid showing whether a cookie is sent for four request contexts under SameSite Strict, Lax, and NoneIs the cookie attached?StrictLax (default)None + SecureSame-site request (app → api, same domain)sentsentsentCross-site top-level navigation (clicked link)withheldsentsentCross-site fetch or XHRwithheldwithheldsentCross-site form POSTwithheldwithheldsentBoxed cell: the localhost login bug — and the same rule that blocks CSRF.None requires Secure, and turns the CSRF protection off — so it needs its own defense.
Figure 5.3 — SameSite, context by context. Lax permits the cookie on cross-site top-level navigations (so following a link into a site keeps you logged in) and withholds it from cross-site fetches and form POSTs — which is what stops CSRF and what breaks the local development setup. The boxed cell is simultaneously the security control working correctly and the bug report you received.

The fixes, ranked. Best: make the local frontend same-site — run it behind a dev proxy at app.localtest.me pointed at a local API, or proxy /v1 through the dev server so the browser sees one origin. Acceptable: point local development at a local or staging API rather than production, which is a better idea for several unrelated reasons. Last resort, with eyes open: SameSite=None; Secure on a development-only cookie, never in production, because it disables the CSRF defense entirely and hands you the obligation to replace it with anti-CSRF tokens.

When it misleads

The near-universal wrong turn here is diagnosing this as CORS. CORS errors are loud and specific — the browser prints a message naming the failed check — while this failure is silent: a well-formed request that simply lacks a header, answered with a perfectly ordinary 401. If your evidence is a 401 and not a console CORS error, cookies are the first suspect, not the last.

Module 06 Caching, the web's superpower

The Cellarbook cold load spends 141 of its 221 ms waiting, and that span exists for exactly one reason: the edge did not have the page. Warm — DNS cached, connection reused, and the no-cache HTML revalidated at the origin with a 304 — the same page arrives in about 124 ms. That collapse from 221 to 124 is not a micro-optimization; it is the difference between a product that feels considered and one that feels sluggish, and it is bought with response headers and connection reuse.

Caching also has the worst failure modes in this course. A cache that is too aggressive traps users on a stale application for a year; a cache that is too permissive serves one customer's data to another, which is not a performance bug but a reportable incident. So this module treats caching as an engineering discipline with two questions, a small vocabulary of directives, and a hierarchy of tiers that all obey the same contract.

Two questions, two mechanisms

A cache holding a copy of a response asks two questions in order. First: may I use this without asking anyone? That is freshness, and it is answered locally from cache-control: max-age — if the response is younger than its stated lifetime, the cache serves it and no packet leaves the machine. If the answer is no, the second question: may I keep using it? That is validation, and it costs a network round trip — the cache sends a conditional request carrying the copy's fingerprint, and the server answers 304 Not Modified if the copy is still correct.

The economics differ sharply and that difference drives every policy decision. A fresh hit saves the entire round trip: zero milliseconds, zero bytes. A successful validation saves only the body — you still pay the full latency of asking, which on the Cellarbook trace is at least 18 ms to the edge and up to 141 ms if the question travels to the origin. Freshness versus validation is therefore not two flavors of the same thing: freshness is free, validation is cheap, and only one of them removes the network from the picture.

The load-bearing idea

Every caching policy is an answer to one question: how wrong am I willing to be, and for how long? A max-age is a promise that serving a stale copy for that many seconds is acceptable. Choose it by asking what breaks if a user sees the old version for that long — not by copying a number from another project.

Cache-Control: the directives that matter

Cache-Control is the contract, and about eight directives cover practice. They fall into three groups: how long, who may store it, and what to do at the boundary.

DirectiveSpeaks toMeansTypical use
max-age=Nevery cacheFresh for N seconds from generationAny cacheable asset
s-maxage=Nshared caches onlyOverrides max-age at CDN and proxy tiersCache at the edge longer than in browsers
no-cacheevery cacheStore it, but revalidate before every useHTML shells; anything that must be current
no-storeevery cacheDo not write it down at allAuthenticated pages, tokens, banking
privateshared cachesOnly the browser may store thisPer-user JSON and pages
publicshared cachesAny cache may store it, even if authenticatedTruly shared assets — and dangerous elsewhere
immutablebrowsersDo not even revalidate on reloadContent-hashed filenames
stale-while-revalidate=Nevery cacheServe stale for N s while refreshing in backgroundHiding origin latency from users

The one that causes the most confusion is no-cache, and the confusion is the name's fault. It does not mean "do not cache." It means "cache this, and revalidate it with the origin before every use" — which is exactly what the Cellarbook HTML uses, paired with an etag, so the browser holds a copy and asks a cheap question rather than downloading 14 KB again. The directive that actually means do not keep a copy is no-store, and it is the correct choice for a page containing another person's data.

When it misleads

public on a response that depends on who asked is the single most expensive mistake in this module. It tells shared caches — the CDN, any corporate proxy — that this response may be served to anybody, which is precisely how one user's account page ends up rendered for another. Per-user responses are private at minimum, and no-store when the content is sensitive enough that a copy on disk is itself the problem.

ETags and conditional requests

An ETag is a fingerprint of a specific representation — the Cellarbook HTML carries etag: "8f2c-1a9d". When the browser's copy goes stale, it does not download blindly; it asks a question that includes the fingerprint:

GET /cellar HTTP/2
host: app.cellarbook.com
if-none-match: "8f2c-1a9d"

HTTP/2 304
etag: "8f2c-1a9d"
cache-control: no-cache

A 304 Not Modified has no body. The client already has the bytes; the server is confirming they are still correct and resetting the freshness clock. For the Cellarbook HTML this turns a 14 KB transfer into roughly 200 bytes, and — critically — leaves the render path unblocked at whatever the round-trip cost was rather than the round trip plus download.

Freshness timeline for one cached object with max-age 600 and stale-while-revalidate 60, showing the fresh window, the grace band, staleness, and a 304 resetting the clockOne object, fetched at T0 with cache-control: max-age=600, stale-while-revalidate=60fresh — served with no network at allstale — must revalidateT0+600s+660s+900sgracerequest at +820sif-none-match → 304 → clock resets to T0In the fresh window the cache never contacts the server —so a bad max-age cannot be recalled.
Figure 6.1 — The life of one cached object. Inside max-age the copy is served with no network contact whatsoever — which is why an over-long max-age cannot be taken back. In the stale-while-revalidate grace band the user gets an instant stale answer while the cache refreshes behind them. After that, every use requires a conditional request; a 304 costs one round trip, carries no body, and resets the clock.

One subtlety worth carrying: ETags identify a representation, not a resource. The brotli-compressed and uncompressed versions of the Cellarbook HTML are different representations and must carry different ETags, or a cache will confidently answer 304 to a client asking about a copy it never had. This is the same fact that Vary exists to express, from the other direction.

Vary and the cache key

A cache stores responses under a key. By default the key is the method plus the URL — which is wrong the moment the server's answer depends on anything else about the request. Vary is how the response says so: vary: accept-encoding on the Cellarbook HTML tells every cache that the request's accept-encoding header is part of the identity of this response, so brotli and non-brotli copies are stored separately.

Omit it and the failure is immediate and absurd: the first client to ask advertises brotli, the edge stores the compressed bytes under the plain URL, and the next client — a monitoring agent, an older library, a server-side integration that never sent accept-encoding — receives brotli-compressed bytes it does not know to decompress and reports the page as corrupt.

The dangerous direction is the same mechanism applied to identity. If a response depends on the authorization or cookie header and is stored in a shared cache under a key that ignores them, the cache will serve the first user's response to everybody. The correct handling is almost never vary: cookie — that produces a cache key so wide it never hits — but private or no-store, which keep per-user responses out of shared tiers entirely.

From your other work

This is worth being precise about in GRC terms: a shared cache serving user A's authenticated response to user B is an unauthorized disclosure of personal data, discovered by users rather than by monitoring, and undetectable in application logs because the origin was never asked. In most frameworks that is a reportable incident with notification obligations, arising from one header on one response. Treat public and s-maxage on authenticated routes as a change requiring the same scrutiny as an access-control change, because it is one.

Worked example

Cellarbook's /v1/cellar returns the requesting user's collection. Correct: cache-control: private, no-cache with an ETag — the browser may keep a copy and revalidate cheaply, no shared cache ever stores it. Incorrect and tempting: public, s-maxage=300 to cut origin load during a traffic spike. The second version works flawlessly in testing, because a test session only ever has one user.

The hierarchy: browser to edge

The same directives are obeyed by every tier, and the tiers differ only in who they serve. The browser cache is private to one person. The CDN edge is a shared cache serving everyone in a region. The origin sits behind both. A request walks down until someone can answer it.

Flowchart of a request passing through the browser cache and then the CDN edge, with the branch for fresh, stale-with-validator, and miss at each tier and the directives that drive themGET /cellarTier 1 — browser cache (private)fresh — serve locally0 ms, no packet sentmax-age not elapsedstale, has validatorask cheaplyno-cache · if-none-matchno entryfirst visit, orno-storeTier 2 — CDN edge (shared)fresh at the shared tierx-cache: HIT · age: 8412~24 ms — never reaches origins-maxage · public · immutablestale, revalidate to origin304 → refresh, then serve+124 ms round trip, no bodystale-while-revalidate hides thisMISS — full origin fetchx-cache: MISS141 ms — the cold traceprivate · no-store never stored hereOrigin — us-east-1Same directives at both tiers. Only the audience differs: one person, or everyone.
Figure 6.2 — The decision, twice. Each tier asks the same two questions in the same order, and the directives naming the tier decide the branch: max-age governs the browser, s-maxage overrides it at the shared tier, private and no-store forbid storage at the edge entirely. The Cellarbook cold trace is the right-hand path all the way down — 141 ms — and the warm trace is the middle path at tier two: the no-cache HTML revalidates at the origin — a conditional check the origin answers almost instantly, so the cost is the pure path: 18 + 106 ≈ 124 ms. The left-hand edge HIT belongs to the hashed assets, not to the HTML.

Two practical consequences complete the picture. Purge does not reach the browser. You can invalidate the edge in seconds; you cannot recall a max-age a browser is already honoring, because a fresh copy is served with no network contact at all. That is why versioned filenames — /assets/app.3f9c1b.js, cached public, max-age=31536000, immutable — are the real invalidation strategy: a deploy changes the hash, which changes the URL, which is a different cache entry, and nothing needs invalidating. The HTML is the pivot. Because the HTML names those hashed assets, it must be current — no-cache with an ETag — so a deploy propagates on the next navigation and self-heals.

The three cache tiers — browser, CDN edge, origin — annotated with the directives each obeys and how far a purge can reachWho obeys what — and how far a purge reachesBrowser cache — private, one personmax-age · no-cache · no-store · immutablestores private responses; ignores s-maxageCDN edge — shared, everyone nearbys-maxage overrides max-age · publicrefuses to store private and no-storeOrigin — us-east-1writes the directives every tier above obeyspurge reaches hereand never herea fresh copy is served withno network contact at allVersioned URLs are the only invalidation strategy that reaches every tier.
Figure 6.3 — The hierarchy, and the limit of purge. The same header drives all three tiers, with s-maxage speaking only to shared caches and private stopping at the browser. A purge can empty the edge in seconds and can never reach a browser holding a fresh copy — which is why a deploy relies on content-hashed URLs rather than invalidation.
Worked example — the 221 ms to 124 ms collapse

Cold: DNS 28, TCP 19, TLS 22, edge MISS with origin fetch 141, download 11 — 221 ms. Warm, minutes later: DNS cached (0), connection reused (0), and then the step that cannot be skipped — the HTML is no-cache, so neither the browser nor the edge may serve it without first revalidating at the origin. The conditional request crosses to us-east-1 and returns a 304 Not Modified, one round trip of about 124 ms with no body to download. Total about 124 ms. Setup and payload vanish; the one cost a no-cache document can never shed is the revalidation round trip. The difference is bought with headers and reuse rather than hardware — but headers cannot cache what they have marked never-stale.

Module 07 One protocol, three transports

Everything in module 4 — methods, status codes, headers, bodies — is identical across HTTP/1.1, HTTP/2, and HTTP/3. The semantics did not change. What changed three times is how those messages are serialized and carried, and each change was aimed at the same enemy: something waiting behind something else.

This module is short because the concepts are few, and it matters because version knowledge is where cargo cult accumulates faster than anywhere else in web performance. An engineer who learned optimization in 2014 carries a playbook whose techniques now measurably hurt. The useful output of this module is not protocol history; it is a short list of things you do differently per version, and the habit of checking which version actually served the page rather than assuming.

HTTP/1.1: keep-alive and the queue

HTTP/1.0 opened a connection per request, which meant paying the handshake — 41 ms, in this course's trace — for every image on the page. HTTP/1.1 fixed that with persistent connections: keep the connection open and send the next request on it. But the protocol still requires responses to come back in order, one at a time per connection. If the first response is slow, everything queued behind it waits, no matter how ready those responses are.

That is head-of-line blocking at the HTTP layer, and browsers worked around it the only way available: open more connections — six per hostname, by convention. Six lanes, each still single-file. From that constraint came an entire generation of techniques: domain sharding (spread assets over img1.example.com, img2… to get 6 more connections each), sprite sheets (glue twenty icons into one image to make one request), inlining small files into the HTML, and concatenating all JavaScript into one bundle. Every one of these traded cache granularity and complexity for fewer requests, and every one was rational under HTTP/1.1.

HTTP/2: multiplexing on one pipe

HTTP/2 keeps every semantic from module 4 and replaces the serialization. Messages become frames tagged with a stream identifier, and frames from many streams interleave freely on one connection. Thirty requests can be in flight simultaneously over a single TCP connection, in any order, with responses arriving as they become ready. Headers are compressed with a shared dynamic table (HPACK), which matters more than it sounds: the repetitive kilobyte of cookies and user-agent strings on every request compresses to a handful of bytes.

The same six-asset page load drawn on one time axis over HTTP/1.1, HTTP/2, and HTTP/3, showing repeated connection setup, multiplexing, and the shorter QUIC handshakeSame six assets, same warm edge, three transportsHTTP/1.16 connections154 ms — five more handshakesHTTP/21 connection113 ms — one handshake, streams interleavedHTTP/3QUIC, 1 RTT92 ms — the saving is the handshake050100150200 msconnection setuprequest and response
Figure 7.1 — Where each version spends its time. Under HTTP/1.1 the five subresources each open a new connection and pay a fresh 41 ms handshake. HTTP/2 reuses one connection, so those five start the moment the HTML is parsed and interleave as streams. HTTP/3 wins the same way plus a shorter handshake — 20 ms instead of 41. Note what does not change across the three panels: the per-request wait and the download time. The transport moved; the server's work did not.

Crucially, the connection underneath is still TCP, which delivers one ordered byte stream. If a single segment is lost, the kernel holds back every subsequent byte until the retransmission arrives — including bytes belonging to entirely unrelated streams that arrived intact. Head-of-line blocking did not die; it moved down one layer, from HTTP to TCP. On a clean network you never notice. On a lossy mobile connection, one connection carrying thirty multiplexed streams can stall all thirty on one lost packet, which is why HTTP/2's benefit is smallest exactly where you most want it.

HTTP/3: the same semantics over QUIC

HTTP/3 is HTTP/2's model carried by QUIC instead of TCP, and the single change that matters is that QUIC's streams are independent at the transport. A lost packet stalls delivery only on the stream whose bytes it carried; every other stream keeps flowing. That is the fix TCP structurally could not deliver, because TCP's contract is a single ordered stream.

Two panels showing one lost packet stalling all three streams under HTTP/2 over TCP, and only one stream under HTTP/3 over QUICHTTP/2 over TCPHTTP/3 over QUICone ordered byte stream underneathindependent streams underneathstream Astream Bstream Cone segment lostall three stall until it is retransmittedstream Astream Bstream Cone packet lostonly stream A waits; B and C keep flowingThe benefit is largest exactly where networks are worst — mobile, congested, lossy.
Figure 7.2 — The blocking moves down, then out. Under HTTP/2, TCP's single ordered stream means one lost segment holds back every byte behind it, including bytes for streams that arrived intact. QUIC tracks delivery per stream, so the loss is contained. On a clean link the two panels are indistinguishable; on a lossy one the difference is the whole point.

Two further QUIC properties from module 3 pay off here. The handshake is one round trip instead of two, worth 21 ms on this trace. And connections are identified by a connection ID rather than by addresses and ports, so a phone switching from Wi-Fi to cellular keeps its connection instead of rebuilding it — a mid-page-load network change that used to be a visible stall becomes invisible.

What you actually do differently

Here is the whole practical payload of this module.

On HTTP/1.1: minimize request count. The old playbook is correct here because its assumptions hold — each request may cost a connection, and the connection budget is six per host.

On HTTP/2: undo the workarounds. Domain sharding now actively hurts: each extra hostname forces a separate connection with its own handshake, its own congestion-control state, and its own prioritization scope — you have manually recreated the problem multiplexing solved. Sprite sheets and mega-bundles hurt for a different reason: they destroy cache granularity, so a one-line change to one component invalidates a megabyte for every user. Split bundles along change-frequency lines instead. What you keep, without exception: compression, caching, and reducing payload size, because HTTP/2 does nothing about bytes or server think time.

On HTTP/3: for most teams this is a CDN toggle. Your origin can keep speaking HTTP/1.1 to the edge while the edge speaks h3 to browsers. The one thing worth understanding is discovery: a browser cannot know an origin speaks h3 before connecting, so it connects over h2 and receives alt-svc: h3=":443", which it remembers and uses on the next connection. That is why your own first test always shows h2, and why concluding "h3 is not enabled" from one page load is wrong.

When it misleads

Neither HTTP/2 nor HTTP/3 fixes a slow server. If your TTFB is 900 ms because a database query is slow, the protocol is idle for 900 ms in every version — and the waterfall shows that plainly as a long "waiting" bar with a trivial download. Upgrading the protocol under those conditions is a plausible-sounding change with zero measurable effect, which is a particularly expensive kind of change to ship.

Worked example

Verify rather than assume. In devtools, right-click the Network panel's column headers and enable Protocol: each request then shows http/1.1, h2, or h3. From the terminal, curl -I --http3 https://app.cellarbook.com/cellar tests h3 directly, and curl -sI https://app.cellarbook.com/cellar | grep -i alt-svc shows whether the origin is advertising it at all. In the canonical Cellarbook trace the ALPN result is h2 on the first connection with alt-svc: h3=":443" present — meaning h3 is enabled and will be used from the second connection onward.

Module 08 Middleboxes and real-time

Figure 1.3 drew the Cellarbook request as a conversation between a browser and a server. That was a useful simplification and it is false. Between the two sit a CDN edge, a load balancer, and often a reverse proxy — each of which terminates a connection, makes its own decisions, and rewrites parts of the request before passing it inward. The client your application sees is not the client that made the request; it is whichever machine last spoke to your application.

This module covers those intermediaries and the two problems they create: whose identity is this? and what happens to connections that stay open? The second question leads into real-time patterns — polling, long polling, SSE, WebSockets — which are all attempts to get server-initiated data out of a protocol designed for the client to ask first, and which all have to survive the middleboxes from the first half.

The boxes in the path

Three shapes cover almost everything. A forward proxy acts on behalf of clients — a corporate egress gateway, a filtering proxy — and the servers it contacts see the proxy, not the user. A reverse proxy acts on behalf of servers: it terminates the client's connection and forwards inward, which is the pattern behind CDNs, TLS terminators, API gateways, and nginx in front of an application. A load balancer distributes work across instances, at layer 4 (routing whole connections by address and port, blind to their contents) or layer 7 (parsing each HTTP request and routing on path, host, or header).

The distinction that matters is not the label but this: any box that terminates TLS is a party to the conversation, not a pipe. It sees the plaintext, it may modify headers, it decides what to forward, and its own failures appear to your users as your failures. A 502 is that machine telling you it could not get an answer from something behind it — which is why, as module 1 argued, the absence of a matching origin log entry is expected rather than mysterious.

The X-Forwarded-For problem

Once a request has been through a terminating hop, your application's notion of "the client address" is the hop's address. The convention that repairs this is X-Forwarded-For: each proxy appends the address it received the request from, building a left-to-right chain of the path so far. (The standardized Forwarded header does the same job with better syntax and less adoption.)

The chain is append-only and completely unauthenticated, which produces the trap. A client can simply send its own x-forwarded-for header, and every proxy in the path will dutifully append to it. Everything to the left of the entry your own edge wrote arrived attacker-controlled.

A request path from client through CDN edge and load balancer to the application, showing the X-Forwarded-For chain growing at each hop and the trust boundary at the operator's own edgeClient203.0.113.7CDN edgeTLS terminatesLoad balancer198.51.100.9Flask app10.0.3.12trust boundary — you control everything to the rightsends its own header:xff: 9.9.9.9edge appends what it saw:9.9.9.9, 203.0.113.7LB appends what it saw:9.9.9.9, 203.0.113.7, 198.51.100.9app reads the chainTrustworthy entry: 203.0.113.7 — the one your edge wroteThe nth-from-rightmost entry, where n is the hops you operate — here the 2nd from the right. Never take the leftmost.attacker-chosen
Figure 8.1 — The chain, and where trust begins. The client supplied 9.9.9.9 and every hop faithfully preserved it, so the leftmost entry — the one most code reaches for — is exactly the one an attacker controls. The trustworthy value is the entry appended by the first hop you operate: the nth-from-rightmost, where n is the number of hops under your control — here the 2nd from the right, so you skip only one. Everything left of that is testimony from strangers.

The failure is quiet, and its consequences are the security controls people care about most. A rate limiter keyed on the leftmost entry can be defeated by rotating a fake value on every request — the attacker is never limited, while a legitimate user unlucky enough to share a spoofed value is. An IP allowlist keyed the same way is bypassable by anyone who reads its documentation. And audit logs recording the leftmost entry contain attacker-chosen strings presented as fact.

From your other work

This is the shape of an audit finding you have probably seen: a control that exists, is documented, passes its test, and is trivially bypassable because it trusts client-supplied input one layer below where anyone was looking. The remediation language is precise — "the application must derive client IP from the nth-from-rightmost entry, where n is the number of proxy hops under our control, and must not parse the header at all when the request did not arrive via those hops." Most frameworks implement this as an explicit trusted-proxy list, which is the setting to check first.

When request/response isn't enough

HTTP's model is that the client asks and the server answers. Some products need the reverse — the server knows something and wants the client to have it now. Four patterns solve this, and they form a ladder of increasing capability and decreasing friendliness to the infrastructure above.

Four real-time patterns on one thirty-second time axis delivering the same two events: polling, long polling, server-sent events, and WebSocketsTwo events, at 4 s and 18 s — delivered four waysPollingevery 2 s15 requests, 2 usefulLong pollingrequest parked until there is news, then reissued — 3 requestsSSEone HTTP response held open, events written into it — 1 request, server to client onlyWebSocketHTTP Upgrade, then frames both ways (gold = client to server) — no longer HTTP010 s20 s30 s
Figure 8.2 — Same two events, four delivery models. Polling issues fifteen requests to deliver two events and is always up to one interval late. Long polling parks the request until there is news — correct results, one held connection per client. SSE holds a single response open and writes events into it, with browser-native reconnection. WebSockets upgrade out of HTTP entirely and carry frames in both directions, which is the only one of the four that lets the client push continuously.

Polling is a loop of ordinary requests. It is trivially compatible with every cache, proxy, and load balancer you own, and it is wasteful in exact proportion to how rarely anything happens. Long polling sends a request the server holds open until it has news, then answers and the client immediately asks again — correct and timely, at the cost of a parked connection per client. SSE holds one HTTP response open forever and writes data: lines into it as events occur; it is still HTTP — ordinary status codes, headers, cookies, and authentication apply — and the browser's EventSource reconnects automatically, including resuming from the last event ID. WebSocket starts as an HTTP Upgrade request and then abandons HTTP: a raw bidirectional frame channel where you build your own message semantics, your own reconnection, and your own authentication story after the handshake.

Choosing the pattern

Three questions settle it: which direction does data flow, how often, and what infrastructure must it survive.

Decision flowchart choosing between request/response, webhooks, SSE, and WebSockets, with the load balancer idle timeout hazard marked on long-lived branchesMust the server initiate?noRequest / responsecacheable, boring, correctyesIs the receiver a server?yesWebhookno connection to keep aliveno — a browserMust the client push too?noSSEstill HTTP; reconnection freeyes, continuouslyWebSocketyou own reconnect and authHazard on both long-lived branches: load balancer idle timeout, default 60 sSend heartbeats inside the interval and raise the timeout deliberately — silence looks like death.
Figure 8.3 — Choosing, and the hazard that outranks the choice. Direction decides the pattern: no push at all means ordinary request/response; a server receiver means a webhook, which needs no held connection; a browser receiving only means SSE; genuine bidirectional traffic means WebSockets. Both long-lived options must survive the intermediaries from section 1, where a 60-second idle timeout is the usual default.

That hazard deserves its own paragraph because it produces one of the most confusing bug reports in this course. A load balancer with a 60-second idle timeout closes any connection that has been silent for a minute — and an SSE stream that has no events to send is, from the load balancer's view, an idle connection. The symptom is exact: connections die at almost exactly 60 seconds, with no error on either side, and reconnect cleanly if the client is well-behaved. Anything that dies at a suspiciously round number of seconds is an idle timeout somewhere in the path until proven otherwise. The fix is a heartbeat — SSE has a comment line (: keepalive) meant for exactly this — plus raising the timeout deliberately rather than discovering it.

When it misleads

WebSockets look like the general solution and are usually the wrong first choice. Choosing them for a server-push-only feed buys you nothing you needed and costs you reconnection logic, heartbeat logic, a separate authentication story after the upgrade, unhelpful load balancer behavior, and the loss of every HTTP tool in module 9 for debugging. If data only flows one way, SSE gives you the feature and keeps the ecosystem.

Worked example

Cellarbook's tasting-notes dashboard polls /v1/notifications every 2 seconds. Per user, that is 43,200 requests a day to deliver a median of six events — 99.99% of the responses say nothing changed, and each one still costs a request line, headers, a cookie, an authentication check, and a database query. One SSE endpoint replaces all of it: one connection per user, six writes, browser-native reconnection, and events arrive in milliseconds instead of up to two seconds late. The rewrite is a day's work and removes a five-figure annual line item at ten thousand users.

Module 09 Reading the wire

Eight modules of theory are worth one afternoon of instrument time. This module hands you the instruments and then returns to the exact trace the course opened with — the same 221 ms, now readable line by line rather than described.

The skill has three parts. Fluency with curl -v and the devtools Network panel, so the wire is observable at all. A triage habit that maps a symptom to the layer that owns it, so you look in one place instead of five. And an evidentiary discipline — reproduce at the lowest layer that shows the symptom, and keep the record — which is the part that separates engineers who fix network problems from engineers who wait for them to stop.

curl -v fluency

The verbose transcript has three prefixes and they are the whole convention: * lines are curl narrating what it did, > lines are the exact bytes it sent, < lines are the exact bytes that came back. Here is the Cellarbook cold trace, abridged only where the certificate details repeat.

$ curl -v https://app.cellarbook.com/cellar -o /dev/null

* Host app.cellarbook.com:443 was resolved.
* IPv4: 151.101.65.140
*   Trying 151.101.65.140:443...
* Connected to app.cellarbook.com (151.101.65.140) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=app.cellarbook.com
*  SSL certificate verify ok.
* using HTTP/2
> GET /cellar HTTP/2
> Host: app.cellarbook.com
> user-agent: curl/8.7.1
> accept: */*
>
< HTTP/2 200
< content-type: text/html; charset=utf-8
< cache-control: no-cache
< etag: "8f2c-1a9d"
< vary: accept-encoding
< x-cache: MISS
< strict-transport-security: max-age=31536000; includeSubDomains
<

Every module in this course is visible in those twenty lines. The resolution result is module 2. TLSv1.3, the cipher, and verify ok are module 3. The ALPN negotiation landing on h2 is module 7 — and note that it happened inside the TLS handshake, before HTTP existed. The request and response headers are module 4. cache-control, etag, vary, and x-cache: MISS are module 6, and that last one explains the 141 ms. Note also what is missing: no content-encoding, because bare curl advertises no accept-encoding — add --compressed and the response changes, which is module 4's negotiation demonstrated rather than described.

The working kit is small. -i shows response headers with the body; -I sends HEAD for headers alone; -L follows redirects; -H adds a header; -b sends cookies; --compressed requests compression; --resolve app.cellarbook.com:443:203.0.113.99 pins a hostname to an address so you can test a new origin before DNS moves; and -w prints timing variables that decompose latency exactly along this course's module boundaries.

$ curl -s -o /dev/null -w "dns %{time_namelookup}  tcp %{time_connect}  tls %{time_appconnect}  ttfb %{time_starttransfer}  total %{time_total}\n" https://app.cellarbook.com/cellar

dns 0.028  tcp 0.047  tls 0.069  ttfb 0.210  total 0.221

Those five numbers are Figure 1.3, measured. They are cumulative, so each span is a subtraction: DNS 28 ms, connect 19, TLS 22, waiting 141, download 11.

The Network panel and the waterfall

The browser's Network panel is the same information with two additions curl cannot give you: everything the page requested, and how those requests contended with each other. Each row's timing bar decomposes into spans that map one-to-one onto this course.

A devtools timing bar for the cellarbook request exploded into its spans, each mapped to a curl timing variable and the module that explains itOne timing bar, read as instrument outputWaiting (TTFB) — 141 ms0110221 msdevtools spancurl equivalentexplained inQueueing / Stalled(none — browser-side)module 07DNS Lookup — 28 mstime_namelookupmodule 02Initial connection — 19 mstime_connectmodule 03SSL — 22 mstime_appconnectmodule 03Waiting (TTFB) — 141 mstime_starttransfermodules 06, 08Content Download — 11 mstime_totalmodules 04, 07The longest span names the module that owns your problem.
Figure 9.1 — The waterfall, mapped to the syllabus. Every span in a devtools timing bar has a curl timing variable and an owning module. Read the bar first, identify the longest span, and the diagnosis is already narrowed to one layer: a long SSL bar is module 3, a long Waiting bar is module 6 or 8, a long Content Download is module 4 or 7. This is Figure 1.3 read instrument-first.

Three habits make the panel trustworthy. Preserve log, so a redirect or a page navigation does not erase the evidence you were about to read — without it, the most interesting request in a login flow vanishes at the moment it matters. Disable cache, so you are measuring cold behavior deliberately rather than accidentally; then turn it off again to measure the warm path, since both numbers are real and describe different users. And Copy as cURL, which is the bridge: right-click any request and you have a shell command reproducing it exactly, headers and cookies included, outside the browser.

The load-bearing idea

Copy as cURL converts a browser mystery into a reproducible experiment. Once the failure exists as a command, you can bisect it — remove one header at a time until it starts working, and the header you removed last is the cause. Almost every "works here, fails there" bug dies within ten minutes of this treatment.

Symptom to layer

Triage is a lookup table plus one command per row. The point is not the table's contents but its shape: each symptom belongs to one layer, and that layer has one instrument.

Triage flowchart from an observed symptom through connection, TLS, HTTP, and policy checks, each branch labeled with the command that tests itObserved symptomCould not resolve hostlayer: DNS — module 02dig +trace host · dig host @1.1.1.1Refused / timeout / resetlayer: TCP or a middlebox — modules 03, 08refused = nothing listening · timeout = path or firewallcurl -v --connect-timeout 5 https://host/Certificate errorlayer: TLS — chain, name, clock — module 03openssl s_client -connect host:443 -servername host4xx or 5xx statuslayer: HTTP — modules 04, 06, 08502/504: the hop behind the responder failedcurl -i · check x-cache and request-id headersWorks in curl, fails in browserlayer: policy — cookies, SameSite, CORS — module 05Copy as cURL, diff against the working commandthe difference is always policy — curl enforces none
Figure 9.2 — Symptom, layer, command. Each branch names both the layer that owns the failure and the one command that tests it. The bottom branch is the highest-value entry: curl enforces no browser policy, so any behavior difference between a browser request and an identical curl request is policy — cookie attributes, CORS, mixed content, or referrer rules — and never the server.

Run that bottom branch on the module 5 bug and watch the method work. The browser sends the request and gets a 401; curl with -b cb_session=… gets a 200. Copy as cURL from the browser, and the copied command also succeeds — which is the decisive observation, because the copied command includes every header the browser sent. If the reproduction with the browser's own headers works and the browser does not, the browser withheld something. Diff the two commands: the working one has a cookie header, the copied one does not. The cookie was never sent, which is SameSite=Lax doing exactly its job. Total elapsed: about four minutes, none of it spent in application code.

The discipline

Three rules turn the instruments into a method.

Reproduce at the lowest layer that still shows the symptom. If it reproduces in curl, stop using the browser — you have removed cookies, CORS, extensions, service workers, and rendering from the problem. If it reproduces with --resolve pinned to a specific origin address, you have removed DNS and load balancing. Each layer you remove is a hypothesis eliminated, and the layer where reproduction finally fails is where the cause lives.

Keep the evidence. Export the HAR before closing the tab; paste the curl transcript into the incident channel rather than describing it. Wire evidence has a property application logs lack: it is complete and it is not shaped by what your code expected. A HAR from the affected user's session settles arguments that three engineers' recollections cannot.

When logs and the wire disagree, the wire wins. This is where module 1's framing pays off. Application logs are testimony — a participant's account, recorded by the same code whose assumptions may be the bug, and silent about everything that never reached it. A HAR export or a curl transcript is documentary evidence of what actually crossed the network. You would not resolve a factual dispute on recollection when the contemporaneous document is available; the same hierarchy applies here, and it is why "there's nothing in the logs" is the beginning of an investigation rather than the end of one.

From your other work

The evidentiary instinct transfers directly. Preserve the record before it is overwritten (HAR before tab close, transcript before the deploy that changes the symptom). Prefer the document to the account. Note who generated each piece of evidence and what they could and could not observe — an origin log cannot testify about a request the edge answered, in the same way a witness cannot testify to a conversation they were not in.

Worked example — the cold and warm pair

Two commands, one conclusion. Cold, cache disabled: dns 0.028 tcp 0.047 tls 0.069 ttfb 0.210 total 0.221, with x-cache: MISS. Warm, minutes later on a reused connection: dns 0.000 tcp 0.000 tls 0.000 ttfb 0.124 total 0.124, returning a 304 Not Modified — the no-cache HTML revalidated at the origin rather than being served from the edge. The 97 ms difference decomposes exactly: 69 ms of setup eliminated by DNS caching and connection reuse, 11 ms of download eliminated because a 304 carries no body, and 17 ms of origin work saved because confirming an ETag is cheaper than rendering the page — but the 124 ms round trip to the origin remains, because a no-cache document must revalidate on every use. That is not an opinion about performance; it is a measurement that names the two modules — 3 and 6 — that own the remaining work.

Concept index

URL
A structured routing instruction: scheme, host, port, path, query, fragment — each part consumed by a different actor.
Encapsulation
Each protocol layer wraps the layer above's payload and owns one responsibility: HTTP the semantics, TLS the secrecy, TCP the reliability, IP the delivery.
Round-trip time (RTT)
The clock that dominates web latency: one question-and-answer between two hosts; handshakes cost RTTs before any content moves.
TTFB
Time to first byte: request sent to first response byte received; the span where server think time hides.
Recursive resolver
The DNS server that does the walking for you — checks its cache, then queries root, TLD, and authoritative servers on your behalf.
Authoritative nameserver
The server that holds the actual zone records and gives the final answer for a domain.
A / AAAA record
The record mapping a hostname to an IPv4 (A) or IPv6 (AAAA) address — the answer the whole lookup exists to find.
CNAME
An alias record pointing one name at another, restarting resolution there; how app.cellarbook.com becomes an edge hostname.
TTL
Each DNS record's cache lifetime in seconds; cutover timing is TTL arithmetic — nothing propagates.
Anycast
One IP address announced from many locations; routing delivers each client to the nearest instance — how CDN edges are near you.
Three-way handshake
SYN, SYN-ACK, ACK: one round trip in which both sides prove reachability and agree on sequence numbers before data flows.
TLS 1.3
The current TLS: key exchange collapsed into one round trip, obsolete ciphers removed, handshake encrypted after the first flight.
SNI
Server Name Indication: the hostname sent in the clear in the ClientHello so a shared IP knows which certificate to present — and one reason HTTPS still leaks where you're going.
Chain of trust
Leaf certificate signed by an intermediate signed by a root in your device's trust store; the padlock is this chain checking out, attesting domain control and nothing more.
HSTS
A response header instructing the browser to refuse plain HTTP for this host for a stated period — closing the first-visit downgrade window.
0-RTT resumption
TLS 1.3's option to send data with the first packet on a resumed session; replayable, therefore safe only for idempotent requests.
QUIC
The transport that merges TCP's job and TLS into one userspace protocol over UDP: one-RTT setup, independent streams, connections that survive network changes.
Safe method
A method promising no server-side effects (GET, HEAD) — the promise that lets prefetchers and caches act without asking.
Idempotent method
A method where repeats leave the same state as one call (GET, PUT, DELETE) — the promise that makes retries legal.
Status code classes
The first digit assigns fault and next action: 2xx done, 3xx follow, 4xx fix your request, 5xx server failed — retryability follows from the class plus method.
Content negotiation
Client states what it accepts (Accept, Accept-Encoding); server states what it chose (Content-Type, Content-Encoding); representation is agreed, not assumed.
Chunked transfer
Body framing for responses of unknown length: sized chunks ending in a zero-length terminator, instead of a Content-Length promise.
Cookie
A server-written key-value pair the browser attaches to matching future requests — HTTP's memory, scoped and constrained by its attributes.
SameSite
The cookie attribute deciding whether cross-site requests carry the cookie: Strict never, Lax only top-level navigations, None always (HTTPS required) — the CSRF control and the localhost-login gotcha.
HttpOnly
Cookie attribute hiding the value from JavaScript — blunts XSS token theft; does nothing against CSRF.
Session
Server-side state referenced by an opaque cookie ID — a claim check: revocation is deleting the row.
Bearer token
Self-contained signed credential (e.g. JWT) presented in a header; whoever bears it, is it — revocation requires reintroducing server state.
Statelessness
HTTP's rule that each request stands alone — the property that lets any server answer any request, and the reason state had to be bolted on deliberately.
Cache-Control
The header carrying caching's contract: max-age and friends per tier; no-cache means revalidate, no-store means never keep.
Freshness vs validation
The two cache questions: serve without asking (within max-age) vs ask cheaply (conditional request, 304) — the first saves the round trip, the second only the bytes.
ETag / 304
A content fingerprint and the your-copy-is-still-true response to If-None-Match — validation's request-response pair.
Vary
The response header naming which request headers widen the cache key; wrong Vary serves the wrong representation — or the wrong user's data.
CDN
A shared HTTP cache distributed to the network edge, obeying the same Cache-Control contract as every other tier — plus s-maxage and purge.
Head-of-line blocking
One stalled item blocking everything queued behind it — at the HTTP layer in 1.1, at the TCP layer under h2, per-stream-only under h3.
Multiplexing
Many request/response streams interleaved on one connection (HTTP/2's frames) — the fix that made connection-count hacks obsolete.
Alt-Svc
The response header advertising that this origin also speaks HTTP/3 — why h3 arrives on the next connection, not the first.
Reverse proxy
A server-side intermediary that terminates client connections and forwards inward — the pattern behind load balancers, CDNs, and TLS termination.
X-Forwarded-For
The append-only chain of client IPs added by each proxy hop; only the entry written by your own edge is trustworthy.
SSE
Server-Sent Events: one long-lived HTTP response carrying a one-way event stream, with reconnection built into the browser.
WebSocket
A bidirectional protocol that starts as an HTTP Upgrade and then leaves HTTP behind — full duplex, at the cost of HTTP's semantics and easy intermediation.
curl -v
The verbose trace showing connection, TLS, and the exact bytes sent and received — the ground-truth instrument for every module in this course.
Waterfall
The devtools timing chart decomposing each request into queueing, DNS, connect, TLS, waiting, and download — the request lifecycle made visible.
HAR
HTTP Archive export of a devtools session — the wire evidence you preserve for the incident review.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.