HTTP Caching: ETag, Cache-Control and Conditional Requests

Intermediate
13 min

HTTP Caching: ETag, Cache-Control and Conditional Requests

The fastest request is the one you never have to answer. HTTP has a complete caching model built in: the server says how long a response stays fresh, clients and CDNs reuse it, and afterwards they revalidate with a cheap conditional request. The same validators also stop two users from overwriting each other's changes. In this lesson you will learn Cache-Control, ETag, 304 Not Modified and If-Match.

Cache-Control Directives

Cache-Control on a response tells every cache between server and user what it may do:

| Directive | Meaning | |---|---| | max-age=60 | Fresh for 60 seconds; no request needed during that time | | s-maxage=300 | Overrides max-age for shared caches such as CDNs | | public / private | Whether a shared cache may store it (private for per-user data) | | no-cache | May be stored but must be revalidated before every reuse | | no-store | Never store (tokens, personal data) | | stale-while-revalidate=30 | Serve the stale copy while fetching a fresh one in the background | | immutable | Will never change; skip revalidation (fingerprinted assets) |

Typical choices: public, max-age=300 for a product catalogue, private, no-cache for /me, no-store for anything containing credentials. no-cache does not mean "do not cache"; that is no-store.

Validators: ETag and Last-Modified

A validator identifies a specific version of a resource. Last-Modified is a timestamp with one-second resolution; ETag is an opaque version string and is preferred. A strong ETag ("a1b2c3") means byte-for-byte identical; a weak one (W/"a1b2c3") means semantically equivalent. Express generates a weak ETag for every res.json body; a hash of the record or a version column gives you a strong one.

Conditional GET and 304 Not Modified

When a cached copy is stale, the client sends the ETag it holds in If-None-Match. If nothing changed, the server answers 304 with no body:

bash
curl -i https://api.example.com/products/42 # HTTP/1.1 200 OK # ETag: "Yx2k9Qm3" # Cache-Control: private, max-age=0, must-revalidate curl -i https://api.example.com/products/42 -H 'If-None-Match: "Yx2k9Qm3"' # HTTP/1.1 304 Not Modified # ETag: "Yx2k9Qm3"

If-Modified-Since works the same way with Last-Modified.

Conditional Writes and Optimistic Concurrency

The same ETag protects updates. A client that read version "Yx2k9Qm3" sends it back in If-Match with its PUT or PATCH. If someone else changed the resource in between, the server refuses:

http
PUT /products/42 HTTP/1.1 If-Match: "Yx2k9Qm3" HTTP/1.1 412 Precondition Failed

The client reloads, merges and retries. This is optimistic concurrency control with no locks and no extra fields. Servers can require it by answering 428 Precondition Required when the header is missing.

Implementing Both in Express

javascript
const crypto = require("crypto"); const etagFor = (obj) => `"${crypto.createHash("sha1").update(JSON.stringify(obj)).digest("base64url")}"`; app.get("/products/:id", (req, res) => { const product = db.get(req.params.id); if (!product) return res.status(404).end(); const etag = etagFor(product); res.set({ ETag: etag, "Cache-Control": "private, max-age=0, must-revalidate" }); if (req.get("If-None-Match") === etag) return res.status(304).end(); res.json(product); }); app.put("/products/:id", (req, res) => { const current = db.get(req.params.id); if (!current) return res.status(404).end(); const ifMatch = req.get("If-Match"); if (!ifMatch) return res.status(428).json({ error: "If-Match header required" }); if (ifMatch !== etagFor(current)) return res.status(412).json({ error: "Resource has changed" }); db.set(req.params.id, req.body); res.set("ETag", etagFor(req.body)).json(req.body); });

Because the header is set before res.json, Express keeps your strong ETag instead of generating its own. Return the new ETag after a write so the client can keep editing.

Tips

  • Add Vary: Accept, Authorization when the body depends on those request headers, so caches key on them.
  • Collections need ETags too; hash the ids plus updatedAt values.
Quick Quiz
Question 1 of 2

What does `Cache-Control: no-cache` mean?

Key Takeaways

  • Cache-Control sets freshness (max-age), scope (public/private) and rules (no-cache, no-store).
  • ETag identifies a version; If-None-Match lets a client revalidate and receive a bodiless 304.
  • If-Match on writes gives optimistic concurrency: a mismatch is 412 Precondition Failed.
  • Set a strong ETag yourself when you need conditional writes; use Vary for headers that change the body.

Next lesson: Rate Limiting and Quotas — protect your API from abuse and share capacity fairly between clients.

HTTP Caching: ETag, Cache-Control and Conditional Requests - REST APIs | CodeYourCraft | CodeYourCraft