---
title: 'HTTP Caching'
source: 'https://academia.sh/en/courses/asynchronous-processing/http-caching'
course: 'Caching, Queues and Asynchronous Processing'
language: en
updated: '2026-08-23T07:00:23+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```bash
# 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.
