Field guide · Nº 14
A field guide to HTTP and the network beneath it
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.
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.
# 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.
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 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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.)
.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.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 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.
.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.
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.
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.
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.
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.
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.
| Anycast | GeoDNS | |
|---|---|---|
| What varies by location | Nothing — the same IP everywhere | The answer itself: different A records |
| Where steering happens | Routing (BGP), after resolution | DNS, during resolution |
| How you observe it | Same address from two cities, different destinations (traceroute differs) | Different addresses from two cities (dig differs) |
| Failover speed | Seconds — withdraw the announcement | Bounded by TTL, plus resolver quirks |
| Common failure | Routing pulls a client to a distant or overloaded site | Resolver 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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. HSTS — strict-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.
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.
"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.
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.
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.
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.
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.
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).
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.
| Method | Safe | Idempotent | Cacheable | What it means in practice |
|---|---|---|---|---|
| GET | yes | yes | yes | Anything may issue it, at any time, twice |
| HEAD | yes | yes | yes | Headers only — cheap existence and freshness checks |
| PUT | no | yes | no | Replace at a known URL; retry freely |
| DELETE | no | yes | no | Second call returns 404 or 204 — state is unchanged either way |
| POST | no | no | rarely | Promises nothing; retries may duplicate |
| PATCH | no | no | no | Not 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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=1209600Read 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.
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.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.
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.
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.
"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.
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.
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.
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.
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.
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.
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 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.
| Directive | Speaks to | Means | Typical use |
|---|---|---|---|
max-age=N | every cache | Fresh for N seconds from generation | Any cacheable asset |
s-maxage=N | shared caches only | Overrides max-age at CDN and proxy tiers | Cache at the edge longer than in browsers |
no-cache | every cache | Store it, but revalidate before every use | HTML shells; anything that must be current |
no-store | every cache | Do not write it down at all | Authenticated pages, tokens, banking |
private | shared caches | Only the browser may store this | Per-user JSON and pages |
public | shared caches | Any cache may store it, even if authenticated | Truly shared assets — and dangerous elsewhere |
immutable | browsers | Do not even revalidate on reload | Content-hashed filenames |
stale-while-revalidate=N | every cache | Serve stale for N s while refreshing in background | Hiding 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.
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.
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-cacheA 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.
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.
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.
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.
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 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.
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.
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.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.
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.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 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.
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 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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Three questions settle it: which direction does data flow, how often, and what infrastructure must it survive.
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.
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.
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.
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.
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.221Those 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 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.
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.
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.
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.
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.
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.
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.
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.
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.