Skip to content
academia.sh

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-age sets 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.
  • public allows a shared cache, private allows only the end user’s cache, no-store allows none. no-cache does 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.

Start typing to search.

↑↓ Esc navigate · open · close