Walk through HTTP caching headers — Cache-Control, ETag, Last-Modified, If-None-Match — and how a conditional request actually works
Freshness and validation are two separate jobs in HTTP caching — one skips the network entirely, the other still asks but can answer with almost nothing.
LEARN BY DOINGPut your intuition to work3 steps
Walk through HTTP caching headers — Cache-Control, ETag, Last-Modified, If-None-Match — and how a conditional request actually works
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two Separate Questions, Two Separate Header Groups
HTTP caching answers two different questions, and the header groups map cleanly onto them. The first question is "can I skip asking the server entirely?" — answered by Cache-Control freshness directives, checked purely against the local clock, with zero network traffic if the answer is yes. The second question, asked only once the answer to the first is no, is "has this actually changed since I last saw it?" — answered by a conditional request, using ETag/If-None-Match or Last-Modified/If-Modified-Since, which still hits the network but can come back with an empty, tiny 304 Not Modified instead of the full response body.
#Cache-Control: The Freshness Layer
Cache-Control directives control whether a cache is even allowed to skip the network, and who's allowed to hold the copy:
| Directive | Meaning |
|---|---|
max-age=N | Treat the response as fresh for N seconds — no request to the server at all during that window. |
no-cache | Not "don't cache" — it means cache it, but revalidate with the server on every use before serving it. |
no-store | The real "don't cache anything, anywhere" directive — never write the response to any cache. |
private | Only the end-user's own browser may cache this; shared caches (CDNs, proxies) must not. |
public | Explicitly allows shared/intermediary caches to store it, even for responses that would otherwise look private (e.g., ones with an Authorization header). |
must-revalidate | Once stale, the cache must revalidate before serving — no serving stale-on-error without asking first. |
stale-while-revalidate=N | Serve the stale copy immediately, and refresh it in the background for up to N seconds. |
immutable | This exact URL's content will never change — skip revalidation entirely even on a hard refresh. |
#Validators: ETag and Last-Modified
Once a cached response goes stale, the client doesn't necessarily need the whole body again — it needs to know whether the body it already has is still correct. That's what a validator is for.
An ETag (entity tag) is an opaque fingerprint of a specific representation of a resource — usually a hash of the content, or a version identifier. The server sends ETag: "abc123" with the response; the client stores it alongside the cached body; on the next request, past max-age, the client sends If-None-Match: "abc123". If the server computes the same tag for the current resource, it knows nothing worth re-sending has changed and returns 304 Not Modified with no body at all. If the content changed, it returns 200 OK with the new body and a new ETag.
Last-Modified is the older, coarser validator: a timestamp of when the resource last changed, checked via If-Modified-Since on the next request. It's cheap to produce (often just a file's mtime) but has a real weakness — most implementations only carry one-second resolution, so two edits inside the same second are indistinguishable, and a file's mtime can change (e.g., after a redeploy that rewrites files byte-for-byte identically) without the actual content changing at all. ETags don't have that problem because they're derived from content, not from a clock, which is why modern APIs favor ETags and keep Last-Modified mostly as a fallback for clients or caches that don't understand ETags.
One more wrinkle worth knowing: ETags can be strong ("abc123", byte-for-byte identical to be considered a match) or weak (W/"abc123", semantically equivalent is good enough — useful when a server can cheaply tell "this means the same thing" but not cheaply guarantee byte-identical output, e.g. after gzip re-compression).
#A Real Conditional Request, End to End
First request — nothing cached yet:
GET /api/products/42 HTTP/1.1Host: api.example.comAccept: application/json
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: max-age=60, must-revalidateETag: "33a64df551425fcc55e4d42a148795d9f25f89d"Last-Modified: Wed, 19 Aug 2026 14:22:31 GMTContent-Length: 48{"id":42,"name":"Wireless Mouse","price":29.99}
Sixty seconds later, max-age has expired and the client must revalidate before it can keep using its cached copy:
GET /api/products/42 HTTP/1.1Host: api.example.comAccept: application/jsonIf-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d"If-Modified-Since: Wed, 19 Aug 2026 14:22:31 GMT
If the product hasn't changed, the server responds without a body at all — no price, no name, nothing, just headers confirming the client's copy is still good:
HTTP/1.1 304 Not ModifiedETag: "33a64df551425fcc55e4d42a148795d9f25f89d"Cache-Control: max-age=60, must-revalidate
That 304 is the entire point of conditional requests: the network round trip still happens, but the payload that would have been dozens of kilobytes for a real response body shrinks to essentially nothing.
#Implementing It Server-Side
Here's what actually generating that ETag and handling the conditional check looks like in an Express route:
const express = require("express");const crypto = require("crypto");const app = express();function computeEtag(payload) {const hash = crypto.createHash("sha1").update(payload).digest("hex");return `"${hash}"`;}app.get("/api/products/:id", (req, res) => {const product = getProductById(req.params.id); // { id, name, price, updatedAt }const body = JSON.stringify(product);const etag = computeEtag(body);res.set("Cache-Control", "max-age=60, must-revalidate");res.set("ETag", etag);res.set("Last-Modified", product.updatedAt.toUTCString());// Conditional GET: if the client's cached copy is still current,// skip re-sending the body entirely.if (req.headers["if-none-match"] === etag) {return res.status(304).end();}res.status(200).type("application/json").send(body);});
The comparison is a plain string check against the incoming If-None-Match header — the expensive part (fetching product, computing its JSON) still has to happen to know the current ETag, but the bandwidth saving on a match is real: a 304 with a couple of headers instead of a full JSON body, multiplied across every client re-checking a resource that hasn't actually changed.
🧠 Check your understanding
0/3 · 0/3 answered1. What does the Cache-Control directive "no-cache" actually mean?
2. What does a 304 Not Modified response contain?
3. Why do ETags generally beat Last-Modified for correctness?