Lesson 04 / 27
Network Performance
Separating freshness from validation, measuring cache headers on a local server, the immutability promise of content-hashed names, and what connection hints actually earn.
Contents
The previous three lessons dealt with the document and the assets themselves: which file, how big, when it gets requested. One more layer exists, and there a gain can get achieved without reducing a single byte. Does the same user opening the station list a second time have to redownload the same files?
The answer to this question lies not in the request but in the headers the response carries. The Caching Strategies lesson in the Browser and the Web Platform course took up program-controlled caching; the subject here is what the server determines with headers alone, without a program getting written.
Freshness and Validation Are Two Separate Decisions
The HTTP cache answers two separate questions, and confusing them is the most common cause of misconfiguration.
Freshness lifetime answers this question: how long can this response be used
without a query? The max-age directive in the Cache-Control header gives a duration;
until the duration expires, the browser makes no request at all. The cheapest request
is the one never made.
The validator answers this question: is the copy I have still valid? The ETag
header carries a signature tied to the content. Once the freshness lifetime expires, the
browser sends this signature back in an If-None-Match header; if the content has not
changed, the server returns a response with no body.
The two decisions complement each other. Freshness prevents the request entirely;
validation makes the request cheap. The no-cache directive leads to a
misunderstanding: it does not forbid storage, it only requires validation before every
use. The directive that forbids storage is no-store.
Measuring the Headers
The following server serves three resources under three separate policies and genuinely handles the conditional request.
// cache-server.mjs — shows freshness and validation headers on three separate resources import { createServer } from "node:http"; import { createHash } from "node:crypto"; const bodies = { "/assets/station.9f2c1a.css": { type: "text/css", content: ".station-list{display:grid;gap:12px}", // Content-hashed name: if the address changes, the content has changed. cache: "public, max-age=31536000, immutable", }, "/api/stations": { type: "application/json", content: JSON.stringify({ stations: ["north-slope-1", "north-slope-2"] }), // Gets validated on every use; the body only gets sent if it changed. cache: "no-cache", }, "/api/announcement": { type: "application/json", content: JSON.stringify({ announcement: "Maintenance window: 03:00-04:00" }), // Fresh for one minute; stale served and revalidated in the background for the next five. cache: "public, max-age=60, stale-while-revalidate=300", }, }; const tag = (content) => `"${createHash("sha256").update(content).digest("hex").slice(0, 16)}"`; createServer((req, res) => { res.sendDate = false; const resource = bodies[req.url]; if (!resource) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("not found\n"); } const validator = tag(resource.content); const headers = { "Cache-Control": resource.cache, ETag: validator, Vary: "Accept-Encoding", }; // Conditional request: the body does not get sent if the client's validator is still valid. if (req.headers["if-none-match"] === validator) { res.writeHead(304, headers); return res.end(); } res.writeHead(200, { ...headers, "Content-Type": resource.type, "Content-Length": Buffer.byteLength(resource.content), }); res.end(resource.content); }).listen(8241, "127.0.0.1", () => console.log("listening: 127.0.0.1:8241"));
#!/usr/bin/env bash # Starts cache-server.mjs, measures freshness and validation behavior, stops it. node cache-server.mjs > /dev/null & server=$! sleep 1 base=http://127.0.0.1:8241 for path in /assets/station.9f2c1a.css /api/stations /api/announcement; do printf '%s\n' "$path" curl -sS -o /dev/null -D - "$base$path" \ | grep -iE '^(HTTP/|cache-control|etag|content-length)' \ | tr -d '\r' | sed 's/^/ /' done printf '\n--- conditional request ---\n' tag=$(curl -sS -o /dev/null -D - "$base/api/stations" \ | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r') printf 'validator held by the client: %s\n' "$tag" curl -sS -o /dev/null -H "If-None-Match: $tag" "$base/api/stations" \ -w 'conditional request -> status %{http_code}, downloaded body %{size_download} bytes\n' curl -sS -o /dev/null "$base/api/stations" \ -w 'unconditional request -> status %{http_code}, downloaded body %{size_download} bytes\n' kill "$server"
/assets/station.9f2c1a.css HTTP/1.1 200 OK Cache-Control: public, max-age=31536000, immutable ETag: "f4c1b262ab403423" Content-Length: 36 /api/stations HTTP/1.1 200 OK Cache-Control: no-cache ETag: "be211c47597a1001" Content-Length: 46 /api/announcement HTTP/1.1 200 OK Cache-Control: public, max-age=60, stale-while-revalidate=300 ETag: "510bd98694f649be" Content-Length: 50 --- conditional request --- validator held by the client: "be211c47597a1001" conditional request -> status 304, downloaded body 0 bytes unconditional request -> status 200, downloaded body 46 bytes
The validator values are stable in this output because they get produced from a digest of the content; they change when the content changes. Port 8241 is an arbitrary choice and must be free.
The measured difference is clear: the conditional request returns with no body. The round trip still happened — a conditional request does not eliminate latency, only transfer. Freshness lifetime eliminates the round trip too. This difference between the two determines which policy gets written on which resource.
The Immutable Asset, the Changing Document
The immutable directive makes a promise: the content at this address will never
change. The promise can only be kept if the address gets derived from the content. The
cache-busting approach in the Rendering Strategies and Infrastructure course provides
exactly that: when a content hash gets written into the file name, the address changes
whenever the content changes, and the old address staying fresh forever creates no
problem.
The document cannot make this promise, because its address is fixed and its content changes with every release. The correct policy for a document is validation, not a long freshness period.
For the case in between, there is the stale-while-revalidate directive: after the
freshness period expires, the old copy gets served immediately for a specified duration
while the refresh happens in the background. The user does not wait; the data refreshes
with one round trip’s delay. It suits content that tolerates delay, like an announcement
banner; it does not suit content like an account balance.
How many bytes each of these three policies transfers on a repeat visit can be computed.
// repeat-visit.mjs — how header policy affects bytes transferred on a repeat visit const HEADER_OVERHEAD = 320; // bytes: the approximate share of request + response headers // The station list page's resources and their selected policy. const resources = [ { name: "document", bytes: 18_400, policy: "no-cache" }, { name: "style", bytes: 62_000, policy: "immutable" }, { name: "entry script", bytes: 148_112, policy: "immutable" }, { name: "shared script", bytes: 101_760, policy: "immutable" }, { name: "font", bytes: 41_600, policy: "immutable" }, { name: "card image", bytes: 86_400, policy: "immutable" }, { name: "station data", bytes: 9_240, policy: "max-age=60" }, ]; const fullSize = (k) => HEADER_OVERHEAD + k.bytes; const firstVisit = resources.reduce((t, k) => t + fullSize(k), 0); // Bytes transferred for one resource on a repeat visit. function repeatBytes(resource, elapsedSeconds, changed) { const hasChanged = changed.has(resource.name); switch (resource.policy) { case "immutable": // Content-hashed name: if it changed, the address has changed, and the new resource downloads from scratch. return hasChanged ? fullSize(resource) : 0; case "no-cache": // Gets validated on every use: if unchanged, only the headers arrive. return hasChanged ? fullSize(resource) : HEADER_OVERHEAD; case "max-age=60": if (elapsedSeconds <= 60) return 0; // still fresh: no request return hasChanged ? fullSize(resource) : HEADER_OVERHEAD; default: return fullSize(resource); } } const scenarios = [ { name: "after 30 seconds, same version", elapsed: 30, changed: new Set() }, { name: "after 300 seconds, same version", elapsed: 300, changed: new Set() }, { name: "new version published", elapsed: 300, changed: new Set(["document", "style", "entry script"]), }, ]; console.log(`first visit: ${(firstVisit / 1024).toFixed(1)} KB`); for (const s of scenarios) { console.log(`\n--- ${s.name} ---`); console.log("resource".padEnd(14) + "repeat".padStart(10) + " policy"); let total = 0; for (const k of resources) { const b = repeatBytes(k, s.elapsed, s.changed); total += b; console.log(k.name.padEnd(14) + `${(b / 1024).toFixed(2)} KB`.padStart(10) + " " + k.policy); } console.log("-".repeat(36)); console.log("TOTAL".padEnd(14) + `${(total / 1024).toFixed(2)} KB`.padStart(10)); console.log(`ratio to first visit: ${(total / firstVisit).toFixed(4)}`); } console.log("\nif no freshness header had been written, every repeat visit ratio would be 1.0000.");
first visit: 458.7 KB --- after 30 seconds, same version --- resource repeat policy document 0.31 KB no-cache style 0.00 KB immutable entry script 0.00 KB immutable shared script 0.00 KB immutable font 0.00 KB immutable card image 0.00 KB immutable station data 0.00 KB max-age=60 ------------------------------------ TOTAL 0.31 KB ratio to first visit: 0.0007 --- after 300 seconds, same version --- resource repeat policy document 0.31 KB no-cache style 0.00 KB immutable entry script 0.00 KB immutable shared script 0.00 KB immutable font 0.00 KB immutable card image 0.00 KB immutable station data 0.31 KB max-age=60 ------------------------------------ TOTAL 0.63 KB ratio to first visit: 0.0014 --- new version published --- resource repeat policy document 18.28 KB no-cache style 60.86 KB immutable entry script 144.95 KB immutable shared script 0.00 KB immutable font 0.00 KB immutable card image 0.00 KB immutable station data 0.31 KB max-age=60 ------------------------------------ TOTAL 224.41 KB ratio to first visit: 0.4892 if no freshness header had been written, every repeat visit ratio would be 1.0000.
The resource sizes are the computation’s input, not the output of a measured application; the header share is also an assumption. What gets computed is the policy’s consequence.
In the first two scenarios, the repeat visit is nearly free. The third scenario teaches the real lesson: when a new version gets published, only the parts that changed download again. Because the shared script did not change, its address did not change either, and it never gets requested. This is where bundling decisions have a performance consequence — if frequently changing code and rarely changing code get grouped into the same chunk, every release redownloads both.
Vary and the Cache Key
If a response varies according to request headers, this must get declared with the
Vary header. Left undeclared, intermediate caches serve a single copy to everyone: a
compressed response can go to a client that does not support compression, or a page
produced for one language can go to a user in another.
The rule is a correctness rule, and it affects performance too: adding an unnecessary
header to the Vary list splits the cache key more than it needs to be and lowers the
hit rate. If a user-specific header enters the list, the cache is effectively disabled.
Connection Hints
Connection hints written into the document tell the browser to do a job early. Each one moves a different stage earlier.
dns-prefetch performs only name resolution. It is the cheapest hint and earns the
least.
preconnect performs name resolution, the transport connection, and the secure
handshake ahead of time. It spends the rounds counted as “connection setup” in the
critical-path model before the resource even gets requested. It has a cost: a connection
that gets established and goes unused holds resources on both the client and the server,
so it should get written only for origins that will definitely be used.
preload requests a specific resource early. As described in the Critical Rendering
Path lesson, this is a correction: it moves a late-discovered critical resource earlier.
The as attribute is required; written incorrectly, the resource downloads twice.
prefetch downloads, at idle priority, a resource that will be needed on the next
navigation. Its priority is the lowest, and it is wasted bandwidth if the user never goes
to that page.
The order is a priority order: flatten the chain first, then preconnect, and preload
last. A hint written without measurement often does harm by taking bandwidth away from
the critical resource.
Summary
- Freshness lifetime prevents the request entirely, the validator makes the request
bodiless; the two solve separate problems, and
no-cacheforbids not storage but unvalidated use. - A content-hashed address is the condition for the
immutablepromise to be kept; a document with a fixed address cannot make this promise and gets managed through validation. - In a new release, only the parts whose address changed download again; a bundling decision is therefore a caching decision.
- The
Varyheader is a correctness matter before it is a performance matter: its absence serves the wrong response, its excess splits the cache. - Connection hints move different stages earlier and each one has a cost; a hint written without measurement cuts off the critical resource.
Next Step
Once the network side is arranged, bytes arrive on time and a repeat visit makes almost no request. The application can still feel heavy, though: the list stutters while scrolling, a letter typed into the filter box appears on screen late, the page may freeze for a moment when the date range changes. None of these problems are in the network; all of them come from downloaded code running in a single queue. The next lesson takes up this queue: it computes how long tasks get measured, what chunking earns, and how layout thrashing scales with row count.
To keep your progress and take notes, Log in
My notes
Log in to take notes.