CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/http-caching-strategy

HTTP caching for web APIs — Cache-Control headers, ETags, conditional requests,

88

25.00x
Quality

84%

Does it follow best practices?

Impact

100%

25.00x

Average score across 4 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
http-caching-strategy
description:
HTTP caching for web APIs — Cache-Control headers, ETags, conditional requests, Vary header, and no-store for sensitive data. Apply AUTOMATICALLY whenever building any API endpoint that serves data — do not wait to be asked.
keywords:
http caching, cache-control, etag, conditional request, vary header, api caching, cache headers, stale-while-revalidate, cache invalidation, api performance, response caching, no-store, no-cache, 304 not modified, if-none-match, if-modified-since, cdn, content negotiation, private, public
license:
MIT

HTTP Caching Strategy

IMPORTANT: Every API endpoint that serves data MUST set appropriate Cache-Control headers. This is not optional. Apply this automatically whenever you build a GET endpoint — do not wait to be asked.

When you are asked to build any API that serves products, catalog items, user profiles, search results, images, or any other data via GET, you must proactively add:

  1. Cache-Control header on EVERY response — either caching directives or explicit no-cache/no-store
  2. ETag header and If-None-Match handling for conditional requests on cacheable endpoints
  3. Vary header when responses differ by Accept, Accept-Encoding, Authorization, or other request headers
  4. no-store on endpoints that return sensitive or authenticated user data

These headers are as fundamental as status codes. You would never return data without a proper status code — likewise, never serve a response without cache headers.


The Problem

An API with no cache headers is a performance and correctness bug:

Client -> GET /api/products      -> 200 OK (no cache headers)
Client -> GET /api/products      -> 200 OK (same data, full round trip, wasted bandwidth)
CDN    -> GET /api/products      -> caches with default heuristic TTL (WRONG — stale data served)
Client -> GET /api/me            -> 200 OK (no cache headers, CDN caches user data for everyone)

Without explicit cache headers, browsers and CDNs use heuristic caching — they guess, and they guess wrong. Sensitive data gets shared. Stale data gets served. Fresh data gets re-fetched unnecessarily.


Cache-Control Header — The Complete Guide

WRONG: No cache headers at all

// WRONG — no cache headers, browser/CDN will guess
router.get("/api/products", (req, res) => {
  const products = db.prepare("SELECT * FROM products").all();
  res.json({ data: products });
});

WRONG: Confusing no-cache with no-store

// WRONG — no-cache does NOT mean "don't cache"
// no-cache means "cache it but revalidate every time"
// For sensitive data you need no-store
router.get("/api/me", authMiddleware, (req, res) => {
  res.set('Cache-Control', 'no-cache');  // WRONG — CDN may still store this
  res.json({ data: req.user });
});

RIGHT: Every endpoint has explicit cache headers

// RIGHT — public catalog data, cacheable with revalidation
router.get("/api/products", (req, res) => {
  res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  const products = getProducts();
  res.json({ data: products });
});

// RIGHT — authenticated user data, never cache or store
router.get("/api/me", authMiddleware, (req, res) => {
  res.set('Cache-Control', 'private, no-store');
  res.json({ data: req.user });
});

// RIGHT — individual product, cacheable with ETag
router.get("/api/products/:id", (req, res) => {
  res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
  const product = getProduct(req.params.id);
  res.json({ data: product });
});

Cache-Control Values — When to Use Each

public, max-age=31536000, immutable — Static assets

Cache-Control: public, max-age=31536000, immutable

For CSS, JS, images with content-hashed filenames (app.abc123.js). Cache for 1 year. The immutable directive tells the browser to never revalidate — the filename changes when the content changes.

You MUST use content hashing in filenames. Without it, users get stale assets after deployments.

public, max-age=60, stale-while-revalidate=300 — Read-heavy API data

Cache-Control: public, max-age=60, stale-while-revalidate=300

For product catalogs, menu items, category lists — data that changes infrequently. Fresh for 60 seconds, serve stale for 5 minutes while revalidating in the background. The stale-while-revalidate directive prevents the "thundering herd" — clients get an instant response instead of all waiting for revalidation.

public, max-age=0, must-revalidate — Always-fresh data with ETag

Cache-Control: public, max-age=0, must-revalidate

For data that could change at any time but benefits from conditional requests. Combined with ETag, the client revalidates every request but gets a 304 Not Modified (no body) when the data hasn't changed — saving bandwidth.

private, no-cache — Per-user data that changes often

Cache-Control: private, no-cache

For order status, notification counts, shopping cart contents. private means CDNs must not cache it. no-cache means the browser must revalidate with the server before using a cached copy. The response IS stored locally but never used without checking.

private, no-store — Sensitive/authenticated data

Cache-Control: private, no-store

For user profiles behind authentication, account details, payment information, tokens, API keys. no-store means the response must NEVER be written to any cache (disk or memory). This is the strongest directive — use it for anything that would be a privacy violation if leaked.

Critical distinction: no-cache vs no-store:

  • no-cache = "store it, but revalidate before using" (still stored on disk!)
  • no-store = "never store this response anywhere"
  • For sensitive data, you MUST use no-store. Using no-cache alone means the data is written to disk cache, which is a security risk.

ETag and Conditional Requests

ETags let the server say "the data hasn't changed, use your cached copy" — saving bandwidth and reducing response times. Always add ETags to cacheable endpoints.

RIGHT: ETag with If-None-Match handling

import crypto from "crypto";

router.get("/api/products", (req, res) => {
  const products = getProducts();
  const etag = `"${crypto.createHash('md5').update(JSON.stringify(products)).digest('hex')}"`;

  res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  res.set('ETag', etag);

  // Check if client's cached version is still current
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // Not Modified — no body sent, saves bandwidth
  }

  res.json({ data: products });
});

Rules for ETags

  1. Always quote ETags: The value MUST be a quoted string: "abc123", not abc123.
  2. Weak vs strong ETags: Use W/"abc123" (weak) when the response is semantically equivalent but not byte-for-byte identical (e.g., different whitespace). Use "abc123" (strong) for byte-identical responses.
  3. Check If-None-Match BEFORE doing expensive work: If possible, compute the ETag from metadata (e.g., updatedAt timestamp) instead of serializing the full response body.
  4. 304 responses must NOT include a body: Call res.status(304).end(), not res.status(304).json().

Efficient ETag from database timestamps

// RIGHT — ETag from metadata, no need to serialize the full response
router.get("/api/products/:id", (req, res) => {
  const meta = db.prepare("SELECT updated_at FROM products WHERE id = ?").get(req.params.id);
  if (!meta) return res.status(404).json({ error: "Not found" });

  const etag = `"${meta.updated_at}"`;
  res.set('ETag', etag);
  res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  const product = db.prepare("SELECT * FROM products WHERE id = ?").get(req.params.id);
  res.json({ data: product });
});

Vary Header — Content Negotiation

The Vary header tells caches that the response differs based on certain request headers. Without it, a CDN might serve a gzip-compressed response to a client that only accepts br, or serve an English response to a French-speaking user.

When to set Vary

// RIGHT — response varies by Accept-Encoding (compression)
// Most frameworks set this automatically, but verify it's there
res.set('Vary', 'Accept-Encoding');

// RIGHT — response varies by Accept header (JSON vs XML)
router.get("/api/products", (req, res) => {
  res.set('Vary', 'Accept, Accept-Encoding');
  res.set('Cache-Control', 'public, max-age=60');
  // ...
});

// RIGHT — response varies by Authorization (different data per user)
router.get("/api/dashboard", authMiddleware, (req, res) => {
  res.set('Vary', 'Authorization');
  res.set('Cache-Control', 'private, no-store');
  // ...
});

Vary rules

  1. Set Vary: Authorization on any endpoint where the response changes based on who is authenticated.
  2. Set Vary: Accept when your API supports multiple content types (JSON, XML, HTML).
  3. Set Vary: Accept-Encoding when serving compressed responses (most frameworks do this automatically).
  4. Never use Vary: * — it effectively disables caching entirely. List specific headers instead.
  5. Combine with Cache-Control: Vary alone doesn't cache or prevent caching. It tells caches to maintain separate entries per unique combination of the listed header values.

Complete Express Example — All Patterns Together

import express from "express";
import crypto from "crypto";

const app = express();

// --- Static assets: long cache with content hashing ---
app.use('/static', express.static('public', {
  maxAge: '1y',
  immutable: true,
}));

// --- Middleware: set Vary header for API routes ---
app.use('/api', (req, res, next) => {
  res.set('Vary', 'Accept-Encoding');
  next();
});

// --- Public catalog: cacheable, ETag, stale-while-revalidate ---
router.get("/api/products", (req, res) => {
  const products = getProducts();
  const etag = `"${crypto.createHash('md5').update(JSON.stringify(products)).digest('hex')}"`;

  res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  res.set('ETag', etag);

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.json({ data: products });
});

// --- Single product: shorter cache, ETag ---
router.get("/api/products/:id", (req, res) => {
  const product = getProduct(req.params.id);
  if (!product) return res.status(404).json({ error: "Not found" });

  const etag = `"${product.updatedAt}"`;
  res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
  res.set('ETag', etag);

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.json({ data: product });
});

// --- Search results: short cache, varies by query ---
router.get("/api/search", (req, res) => {
  res.set('Cache-Control', 'public, max-age=30');
  res.set('Vary', 'Accept-Encoding');
  const results = search(req.query.q);
  res.json({ data: results });
});

// --- Authenticated user profile: never cache ---
router.get("/api/me", authMiddleware, (req, res) => {
  res.set('Cache-Control', 'private, no-store');
  res.set('Vary', 'Authorization');
  res.json({ data: req.user });
});

// --- Order status: private, revalidate every time ---
router.get("/api/orders/:id", authMiddleware, (req, res) => {
  res.set('Cache-Control', 'private, no-cache');
  res.set('Vary', 'Authorization');
  const order = getOrder(req.params.id, req.user.id);
  res.json({ data: order });
});

// --- Admin mutation: no caching, invalidate related caches ---
router.post("/api/admin/products", authMiddleware, (req, res) => {
  res.set('Cache-Control', 'private, no-store');
  const product = createProduct(req.body);
  // Invalidate in-memory cache if using one
  productCache.invalidate();
  res.status(201).json({ data: product });
});

Decision Guide — Which Cache-Control for Which Endpoint

Endpoint typeExampleCache-ControlETag?Vary?
Static assets/static/app.abc123.jspublic, max-age=31536000, immutableNo (filename hashing)No
Public catalog/listGET /api/productspublic, max-age=60, stale-while-revalidate=300YesAccept-Encoding
Single public resourceGET /api/products/:idpublic, max-age=30, stale-while-revalidate=60YesAccept-Encoding
Search resultsGET /api/search?q=...public, max-age=30OptionalAccept-Encoding
Authenticated user dataGET /api/meprivate, no-storeNoAuthorization
User-specific mutable dataGET /api/orders/:idprivate, no-cacheOptionalAuthorization
Sensitive data (tokens, passwords)GET /api/settings/api-keyprivate, no-storeNoAuthorization
POST/PUT/DELETE mutationsPOST /api/productsprivate, no-storeNoNo
Health checkGET /healthno-cacheNoNo

Common Mistakes

Mistake 1: No cache headers at all

Every response gets browser heuristic caching. CDNs cache with unpredictable TTLs. Stale data is served silently. Fix: Set Cache-Control on every endpoint, even if the value is no-store.

Mistake 2: Using no-cache when you mean no-store

no-cache still stores the response on disk. For sensitive data, this means user tokens or personal data sitting in the browser's disk cache. Fix: Use no-store for any endpoint returning authenticated or sensitive data.

Mistake 3: Missing private on authenticated endpoints

Without private, shared caches (CDNs, proxies) may cache user-specific responses and serve User A's data to User B. Fix: Always set private on endpoints that require authentication or return per-user data.

Mistake 4: No Vary header on content-negotiated responses

A CDN caches the JSON response and serves it to a client requesting XML. Fix: Set Vary: Accept when your API supports multiple content types.

Mistake 5: ETags without conditional request handling

Setting the ETag header but never checking If-None-Match means clients send ETags for nothing — the server always sends the full response. Fix: Always check req.headers['if-none-match'] and return 304 when matched.

Mistake 6: Static assets without content hashing

Setting max-age=1y on /app.js means users get stale code for a year after deployment. Fix: Use content-hashed filenames (app.abc123.js) so the URL changes when the content changes.


When to Apply — ALWAYS for GET Endpoints

Rule: If you are building a GET endpoint that returns data, set Cache-Control headers. No exceptions. Do not wait for the task to mention caching, performance, or headers — add them proactively.

  • Public/catalog data -> public, max-age=60, stale-while-revalidate=300 + ETag
  • Authenticated/sensitive data -> private, no-store + Vary: Authorization
  • Per-user mutable data -> private, no-cache + Vary: Authorization
  • Static files -> public, max-age=31536000, immutable + filename hashing
  • POST/PUT/DELETE responses -> private, no-store

Checklist

  • EVERY GET endpoint sets an explicit Cache-Control header
  • Public/catalog endpoints use public, max-age=N, stale-while-revalidate=M
  • Authenticated endpoints set private, no-store and Vary: Authorization
  • Sensitive data endpoints (user profiles, tokens, keys) use no-store, NOT no-cache
  • Cacheable endpoints return ETag and handle If-None-Match with 304
  • ETag values are quoted strings: "abc123"
  • 304 responses send no body (res.status(304).end())
  • Static assets use immutable + content-hashed filenames
  • Vary header set when response differs by Accept, Accept-Encoding, or Authorization
  • POST/PUT/DELETE responses set no-store
  • No use of Vary: * (kills caching entirely)

Verifiers

  • cache-headers-set — Set Cache-Control headers on every API endpoint
  • sensitive-data-no-store — Use no-store (not no-cache) for authenticated/sensitive endpoints
  • etag-conditional-requests — Add ETag and If-None-Match handling to cacheable endpoints
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/http-caching-strategy badge