LearnFrenzy
Sign in Take a free test

Advanced HTTP Patterns · Lesson 19 of 30

REST API Caching: Cache-Control, ETag and 304

4 min read Updated Nov 8, 2026 Saurabh Samir

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

DirectiveMeaningExample use
max-age=Nfresh for N secondscourse catalogue: max-age=300
publicshared caches (CDNs) may store itdata identical for every user
privateonly the user's own browser may store it/api/me
no-cachemay store, but must revalidate (ETag) before each reusedata that changes unpredictably
no-storenever store anywherepayments, tokens, personal data
s-maxage=Nfreshness for shared caches onlyCDN keeps it longer than browsers
stale-while-revalidate=Nserve stale while fetching fresh in the backgrounddashboards, 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(', '));

Real output of the client

$ 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

  1. First request200 with the body and ETag "abc…"
  2. Next requestsends If-None-Match: "abc…"
  3. Unchanged304 Not Modified, empty body – the client reuses its copy
  4. 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-Match for 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-Since work the same way with timestamps, at one-second precision.

What to cache

  • Cache: reference data, catalogues, public content, images – with public, max-age plus ETags.
  • Cache privately: user-specific reads – private, no-cache with 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 Vary header.
  • Only GET and HEAD responses are cached by HTTP caches; POST/PUT/PATCH/DELETE invalidate the URL they touch.

Practice

Try it yourself

  1. Choose Cache-Control for: (a) the list of countries, (b) GET /api/me/orders, (c) POST /api/payments response, (d) a public leaderboard updated every minute.

    Show solution

    (a) public, max-age=86400 with an ETag. (b) private, no-cache with 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.

Next lesson

Search LearnFrenzy

Esc

Quick links