Skip to content
academia.sh

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-cache forbids not storage but unvalidated use.
  • A content-hashed address is the condition for the immutable promise 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 Vary header 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.

Start typing to search.

↑↓ Esc navigate · open · close