Caching and conditional requests — HTTP and REST · Foundations

The fastest request is the one never sent, and the next fastest is answered with "you already have it". Let caches keep what never changes, tag each cat with an ETag, answer 304 to a client whose copy is current, and refuse a write based on a copy that is not.

What you will learn

Read the theory for Caching and conditional requests

All HTTP and REST lessons

All Foundations courses

loading types…

What you'll learn

  • Control how long a response may be reused with Cache-Control, and tell no-cache from no-store
  • Tag a representation with an ETag and answer If-None-Match with 304 Not Modified
  • Prevent lost updates with If-Match, answering 412 for a stale write and 428 for one that names no version

Caching and conditional requests

The fastest request is the one that is never sent. The next fastest is answered with "you already have it", in a few bytes and no body. HTTP builds both into the protocol, through headers that caches along the way (the browser's own, a company proxy, a CDN) all understand. The same headers solve a problem that has nothing to do with speed: two people editing the same record, where the second save silently erases the first. This lesson is about both.

Freshness: may this be reused?

Cache-Control on a response says how a cache may treat it:

DirectiveMeaning
max-age=3600fresh for an hour: reuse it without asking the server
no-cachestore it, but check with the server before every reuse
no-storedo not store it at all (secrets, one-time answers)
privateonly the user's own browser may store it, not a shared cache
publicany cache may store it, even for a request that carried credentials

no-cache is the most misread header in HTTP: it does not mean "do not cache". It means "cache, and revalidate", which is exactly right for data that changes whenever someone edits it. A list of breeds that never changes can carry max-age; a cat that anyone may rename should be revalidated.

Validation: is my copy still current?

To revalidate, the client needs a way to name the version it has. The server gives each representation an ETag, an opaque quoted string that changes whenever the representation does:

HTTP/1.1 200 OK
ETag: "3-7"
Cache-Control: no-cache

The tag can be anything that changes with the content: a version number, a hash of the body, a last-updated timestamp. Here it is the cat's id and a version counter. Next time, the client sends the tag back, and the server compares:

GET /cats/3
If-None-Match: "3-7"

HTTP/1.1 304 Not Modified
ETag: "3-7"

304 Not Modified has no body: the client uses the copy it has. It still carries the ETag and Cache-Control headers, so the cache knows the copy is good for another round. If-None-Match may list several tags, "3-6", "3-7", or be *; any match counts. (A tag written W/"3-7" is a weak one, meaning "equivalent", not "byte for byte identical"; this lesson's are strong.)

Conditional writes: the lost update

Two clients read cat 3 at version 7. The first changes its name and saves. The second, still holding version 7, changes the age and saves too, and its PUT replaces the whole cat, erasing the first client's name. Nobody gets an error. This is the lost update, and the fix is to make the write conditional on the version it was based on:

PUT /cats/3
If-Match: "3-7"

The server compares If-Match with the current tag. If they differ, someone else wrote first, and the answer is 412 Precondition Failed: fetch it again, look at what changed, and decide. A server that insists on this answers a write with no If-Match at all with 428 Precondition Required. Here is the shape of the check on owners:

const ifMatch = c.req.header('if-match');
if (ifMatch === undefined) return c.json({ error: 'Say which version you are replacing' }, 428);
if (ifMatch !== currentTagOf(owner)) return c.json({ error: 'Someone changed this owner first' }, 412);

A successful write creates a new version, so it answers with the new tag, which is what the client sends next time.

Your task

  1. GET /breeds carries cache-control: max-age=3600.
  2. GET /cats/:id carries an ETag of "<id>-<version>", quotes included, and cache-control: no-cache; when If-None-Match names that tag (it may list several, or be *), it answers 304 with no body and the same two headers.
  3. PUT /cats/:id answers 428 with { "error": "Send If-Match with the ETag you last saw" } when there is no If-Match, and 412 with { "error": "The cat has changed since you read it" } when it does not name the current tag.
  4. A PUT that goes through increases the version by one and answers the cat with its new ETag.

When it fails

  • The ETag check fails with 1-1 against "1-1": an ETag is a quoted string, and the quotes are part of the header's value.
  • "Revalidate with a list" answers 200: the header was compared whole. Split on commas and trim each tag.
  • "Revalidate" is missing its ETag: the header was set after the 304 was returned. A 304 carries the same validators as a 200.
  • "The lost update, prevented" is accepted: the version was not bumped, so the old tag still matches.

Remember

  • max-age says how long a response is fresh; no-cache means revalidate, no-store means never keep it.
  • An ETag names a version of a representation and must change when it does.
  • If-None-Match with the current tag earns a 304 with no body.
  • If-Match makes a write conditional: 412 when the tag is stale, 428 when there is none.
Stuck? Show a hint

An ETag is a quoted string, so the header's value includes the quotes: `"1-1"`. If-None-Match and If-Match may list several tags separated by commas, or be *. c.body(null, 304) answers with no body; set the ETag and Cache-Control headers before it, since a 304 carries them too. Bump the version before computing the tag the PUT answers with.