Lesson 06 / 25
HTTP Caching
The response carrying its own cache rules: ETag and Last-Modified validators producing a 304 on conditional requests, the body transfer dropping to zero, the validator changing when the resource changes, and choosing the public, private, max-age, and no-store directives, along with stale-while-revalidate, by resource type.
Contents
Every cache in the previous lessons lived inside the application: the key was generated in code, the entry was stored in code, invalidation was triggered in code. That control ends the moment the response is sent to the client. The layers called “uncontrollable” in the first lesson — the browser cache, the shared caches in between, the edge layer — do not know what the code thinks; they only look at what is written in the response’s headers.
This is why HTTP has its own cache vocabulary, and it answers two questions separately: how long a response counts as fresh, and once its freshness runs out, how to ask whether the stored copy is still valid without downloading the body from the origin. This lesson answers both questions against real requests.
A Server That Generates Validators
The server below serves four endpoints of the library API. A validator is generated from the digest of each response body, and conditional requests are answered against this value. The date header on responses is held fixed, so the measurement’s output is the same on every run.
// http-caching.mjs — the library API: validators and cache-control directives import { createServer } from "node:http"; import { createHash } from "node:crypto"; const books = new Map([[7, { bookId: 7, title: "Sand: Borges Selection", author: "Anthology", on_shelf: 3 }]]); const stock = { branchId: 1, on_shelf: 137 }; const history = { memberId: 4, total: 12 }; const MODIFIED = new Map([[7, new Date("2025-07-01T09:00:00Z")]]); // last-modified timestamps const makeEtag = (body) => `"${createHash("sha256").update(body).digest("hex").slice(0, 16)}"`; function respond(request, response, { body, cacheControl, lastModified = null, vary = null }) { const text = JSON.stringify(body); const etag = makeEtag(text); const headers = { "cache-control": cacheControl, etag }; if (lastModified) headers["last-modified"] = lastModified.toUTCString(); if (vary) headers.vary = vary; const incomingEtag = request.headers["if-none-match"]; const incomingDate = request.headers["if-modified-since"]; const etagMatches = incomingEtag !== undefined && incomingEtag.split(",").map((e) => e.trim()).includes(etag); const dateMatches = !etagMatches && incomingDate !== undefined && lastModified !== null && Math.floor(lastModified.getTime() / 1000) <= Math.floor(Date.parse(incomingDate) / 1000); if (etagMatches || dateMatches) { response.writeHead(304, headers); return response.end(); } response.writeHead(200, { ...headers, "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text) }); response.end(text); } const server = createServer((request, response) => { response.sendDate = false; // to keep the output deterministic const path = new URL(request.url, "http://local").pathname; if (request.method === "POST" && path === "/book/7") { // the book is updated: the validator changes const book = books.get(7); book.on_shelf -= 1; MODIFIED.set(7, new Date("2025-07-02T14:30:00Z")); response.writeHead(204, { "cache-control": "no-store" }); return response.end(); } if (path === "/book/7") { // a rarely changing shared resource return respond(request, response, { body: books.get(7), cacheControl: "public, max-age=60", lastModified: MODIFIED.get(7), vary: "Accept-Language" }); } if (path === "/branch/1/stock") { // a frequently changing shared resource return respond(request, response, { body: stock, cacheControl: "public, max-age=5, stale-while-revalidate=30" }); } if (path === "/member/4/history") { // a resource private to one person return respond(request, response, { body: history, cacheControl: "private, max-age=30" }); } if (path === "/loan/approval") { // a response that must never be stored return respond(request, response, { body: { approval: "U-4711", remaining: 2 }, cacheControl: "no-store" }); } response.writeHead(404, { "cache-control": "no-store" }); response.end(); }); server.listen(8547, "127.0.0.1", () => console.log("library API 127.0.0.1:8547"));
The block that starts and measures the server is separate. The validator and the last-modified date are read from the response and placed as a conditional header on later requests.
# The responses' cache headers, and the result of conditional requests node http-caching.mjs & server=$! sleep 0.5 K=http://127.0.0.1:8547 headers() { curl -sS -D - -o /dev/null "$@" | tr -d '\r' | grep -iE '^(HTTP/|etag|last-modified|cache-control|vary)'; } measure() { curl -sS -o /dev/null -w "status=%{http_code} downloaded=%{size_download} bytes\n" "$@"; } echo "== 1. first request: validators and directives" headers "$K/book/7" ETAG=$(curl -sS -D - -o /dev/null "$K/book/7" | tr -d '\r' | awk '/^[Ee]tag:/ {print $2}') DATE=$(curl -sS -D - -o /dev/null "$K/book/7" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p') echo "== 2. requesting the same resource conditionally" measure "$K/book/7" measure -H "If-None-Match: $ETAG" "$K/book/7" measure -H "If-Modified-Since: $DATE" "$K/book/7" echo "== 3. once the resource changes, the old validator no longer matches" curl -sS -o /dev/null -X POST "$K/book/7" measure -H "If-None-Match: $ETAG" "$K/book/7" headers "$K/book/7" | grep -iE '^(etag|last-modified)' echo "== 4. directives by resource type" for path in /book/7 /branch/1/stock /member/4/history /loan/approval; do printf '%-18s %s\n' "$path" "$(curl -sS -D - -o /dev/null "$K$path" | tr -d '\r' | sed -n 's/^[Cc]ache-[Cc]ontrol: //p')" done kill $server
library API 127.0.0.1:8547 == 1. first request: validators and directives HTTP/1.1 200 OK cache-control: public, max-age=60 etag: "0853c3f417fbfc00" last-modified: Tue, 01 Jul 2025 09:00:00 GMT vary: Accept-Language == 2. requesting the same resource conditionally status=200 downloaded=79 bytes status=304 downloaded=0 bytes status=304 downloaded=0 bytes == 3. once the resource changes, the old validator no longer matches status=200 downloaded=79 bytes etag: "5633883a8bff3669" last-modified: Wed, 02 Jul 2025 14:30:00 GMT == 4. directives by resource type /book/7 public, max-age=60 /branch/1/stock public, max-age=5, stale-while-revalidate=30 /member/4/history private, max-age=30 /loan/approval no-store
Freshness and Validation
The second part of the output separates two different jobs. The unconditional
request returned 200, and 79 bytes were downloaded. When the same resource was
requested with the If-None-Match header, the response was 304, and the downloaded
body dropped to zero bytes. The third line got the same result with
If-Modified-Since.
What a 304 Not Modified response saves is the body, not the round trip. The request still went to the server, and the server still computed the validator. The saving grows as the body grows: negligible for a seventy-nine-byte book record, decisive for a fifty-kilobyte list response.
Freshness comes before validation. A response marked max-age=60 counts as fresh
for sixty seconds, and during that time the cache never asks the server at all. Once
that time runs out, the copy is not discarded; it moves to a stale state and gets
validated on the next request. If validation returns 304, the copy counts as fresh
again. The two mechanisms work together: max-age eliminates the round trip, the
validator eliminates the body.
The third part proves what the validator means. After the book was updated with a POST request, the conditional request made with the old validator still on hand returned 200, not 304, and the body was downloaded again. The new response’s tag and last-modified date have both changed. Because the validator is tied to the resource’s content, this behavior is correct by construction.
There is a distinction between the two validators. The last-modified date’s resolution is one second: for a record that changes twice within the same second, the old copy can be mistaken for valid. The tag generated from the body digest has no such limit. In exchange, computing the tag requires producing the body; the date, on the other hand, is usually read straight from the record’s own column and can be answered without running a query.
What the Directives Mean
The four lines in the last section are four decisions made for four different resource types.
public, max-age=60 — book detail returns the same thing to every user and changes
rarely. public says the response can be stored in shared caches.
public, max-age=5, stale-while-revalidate=30 — a branch’s stock count changes
often. It counts as fresh for five seconds; for the following thirty seconds, the
stale copy keeps being served while the cache refreshes it in the background. This is
the HTTP counterpart of the behavior set up in-process in the previous lesson; the
difference is that the decision is now carried out by the intermediate cache, not the
application.
private, max-age=30 — a member’s loan history is private to that person. private
says the response can be stored only in the end user’s own cache, and cannot be
placed in a shared cache. This is the HTTP counterpart of the tenant leak from the
Cache Key Design lesson, and it is prevented with a single header line.
no-store — a loan approval must not be stored anywhere. This directive is often
confused with no-cache: no-cache does not forbid storing the response, it only
requires validation before it is used. For a response that must not be stored, the
correct directive is no-store.
A separate lifetime can be given for shared caches: s-maxage binds only shared
caches, and it overrides max-age. This is the route used when a long lifetime is
wanted at the edge layer and a short one in the browser.
Vary and the Shared Key
The Vary: Accept-Language header on the book response changes the shared cache’s
key. The Cache Key Design lesson measured that a cache key must carry every input
that changes the response; in HTTP, these inputs are request headers, and Vary
declares them by name. Without this header, the cache uses only the address as the
key, and a user requesting Turkish could be served an English response.
There are two dangerous values for the Vary header. A response marked
Vary: Cookie or Vary: Authorization is technically correct but practically
uncacheable: because every user’s cookie differs, every request lands on a separate
key, and the hit ratio drops to zero. This is exactly the “extra field” row measured
in the Cache Key Design lesson. For responses private to one person, the correct fix
is private, not Vary.
Summary
- HTTP caching is two separate mechanisms:
max-agesets how long a response counts as fresh, and the validator determines whether a stale copy is still valid. - When the conditional request returned 304, the downloaded body dropped from 79 bytes to 0; the round trip still happened. The saving is in the body, and it grows as the body grows.
- When the resource was updated, the tag generated from the body digest and the last-modified date both changed; the request made with the old validator got 200 instead of 304.
- The last-modified date’s resolution is one second; the tag generated from the body digest has no such limit, but producing it requires computing the body.
publicallows a shared cache,privateallows only the end user’s cache,no-storeallows none.no-cachedoes not forbid storing the response; it forbids using it without validation.
Next Step
Throughout this topic, every lesson produced a number: the number of queries reaching the origin, the number of stale reads, hits and misses, downloaded bytes. Each of these numbers was meaningful on its own, but none of them answers the question “is the cache working” by itself. In the Cache Key Design lesson, the highest hit ratio belonged to the wrong implementation; in another setup, a high hit ratio can come from cheap keys that are never even read. The next lesson ties these numbers into a set of metrics: how the hit ratio is calculated, what it hides, what it does to average response time once combined with the miss penalty, and how the hit ratio behaves as cache capacity changes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.