Advanced HTTP Patterns · Lesson 19 of 30
REST API Caching: Cache-Control, ETag and 304
The fastest request is the one never sent; the next fastest returns no body. HTTP has caching built in – browsers, CDNs and proxies all understand it – and REST's "cacheable" constraint exists to take advantage of it. Two tools do most of the work: Cache-Control says how long a response may be reused, and ETags let a client ask "has this changed?" and get a tiny 304 Not Modified when it hasn't.
In this lesson
- Cache-Control directives
- ETags and conditional GET
- 304 Not Modified, shown live
- What to cache and what never to cache
Cache-Control
| Directive | Meaning | Example use |
|---|---|---|
max-age=N | fresh for N seconds | course catalogue: max-age=300 |
public | shared caches (CDNs) may store it | data identical for every user |
private | only the user's own browser may store it | /api/me |
no-cache | may store, but must revalidate (ETag) before each reuse | data that changes unpredictably |
no-store | never store anywhere | payments, tokens, personal data |
s-maxage=N | freshness for shared caches only | CDN keeps it longer than browsers |
stale-while-revalidate=N | serve stale while fetching fresh in the background | dashboards, feeds |
ETags and conditional requests
// server.js
import express from 'express';
import { createHash } from 'node:crypto';
const app = express();
app.use(express.json());
app.set('etag', false); // we'll set our own ETags
let catalogue = { version: 1, courses: ['REST API', 'React.js', 'Next.js'] };
const etagOf = (body) => `"${createHash('sha1').update(JSON.stringify(body)).digest('hex').slice(0, 16)}"`;
app.get('/api/catalogue', (req, res) => {
const etag = etagOf(catalogue);
res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60' });
if (req.get('If-None-Match') === etag) {
return res.status(304).end(); // unchanged: no body at all
}
res.json(catalogue);
});
app.post('/api/catalogue/courses', (req, res) => {
catalogue = { version: catalogue.version + 1, courses: [...catalogue.courses, req.body.title] };
res.status(201).json(catalogue);
});
app.get('/api/me/payment-methods', (req, res) => {
res.set('Cache-Control', 'no-store').json([{ brand: 'visa', last4: '4242' }]);
});
app.listen(process.env.PORT ?? 3000);
// client.js
const API = 'http://localhost:3000/api/catalogue';
let cached = null; // a tiny client-side cache
async function getCatalogue() {
const headers = cached ? { 'If-None-Match': cached.etag } : {};
const res = await fetch(API, { headers });
if (res.status === 304) {
console.log(`304 Not Modified – reuse cached copy (v${cached.body.version}), 0 bytes downloaded`);
return cached.body;
}
const body = await res.json();
cached = { etag: res.headers.get('ETag'), body };
console.log(`200 OK – downloaded v${body.version}, ETag ${cached.etag}, Cache-Control: ${res.headers.get('Cache-Control')}`);
return body;
}
await getCatalogue(); // first call: full response
await getCatalogue(); // unchanged: 304
await fetch('http://localhost:3000/api/catalogue/courses', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'TypeScript' }),
});
const latest = await getCatalogue(); // changed: new ETag, full response
console.log(latest.courses.join(', '));
$ node client.js 200 OK – downloaded v1, ETag "3c73b3730b128abd", Cache-Control: public, max-age=60 304 Not Modified – reuse cached copy (v1), 0 bytes downloaded 200 OK – downloaded v2, ETag "84656e92cbd72110", Cache-Control: public, max-age=60 REST API, React.js, Next.js, TypeScript
Conditional GET
- First request200 with the body and ETag "abc…"
- Next requestsends If-None-Match: "abc…"
- Unchanged304 Not Modified, empty body – the client reuses its copy
- Changed200 with the new body and a new ETag
- An ETag is a fingerprint of the representation – a hash of the content or a version number. Strong ETags (
"…") mean byte-identical; weak ones (W/"…") mean semantically equivalent. - Express already adds weak ETags and answers
If-None-Matchfor you; the manual version above shows what happens underneath, and lets you base the ETag on a database version column instead of hashing the body. Last-Modified/If-Modified-Sincework the same way with timestamps, at one-second precision.
What to cache
- Cache: reference data, catalogues, public content, images – with
public, max-ageplus ETags. - Cache privately: user-specific reads –
private, no-cachewith ETags still saves bandwidth. - Never cache: anything with secrets or payment data –
no-store. - Responses that vary by a header (Accept, Authorization, Accept-Language) need a matching
Varyheader. - Only GET and HEAD responses are cached by HTTP caches; POST/PUT/PATCH/DELETE invalidate the URL they touch.
Practice
Try it yourself
Choose Cache-Control for: (a) the list of countries, (b)
GET /api/me/orders, (c)POST /api/paymentsresponse, (d) a public leaderboard updated every minute.Show solution
(a)
public, max-age=86400with an ETag. (b)private, no-cachewith an ETag. (c)no-store. (d)public, max-age=30, stale-while-revalidate=60.
Quick check
What does a client send to revalidate with an ETag?
If-None-Match: "etag-value".
What does 304 contain?
Headers only – no body; the client reuses its cached copy.
no-cache vs no-store?
no-cache may store but must revalidate; no-store must never store.
Summary
- Cache-Control decides who may cache and for how long.
- ETags + If-None-Match turn repeat downloads into tiny 304 responses.
- Cache public data generously, user data privately, and secrets never.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.