HTTP Caching Guide: Cache-Control, ETag, and 304 Explained

A practical guide for developers who need fast pages without serving stale HTML, broken API data, or mystery CDN responses.

16 min read Web Development & APIs

HTTP caching is the part of web performance that feels simple until the wrong user gets yesterday's HTML. At the protocol level, a cache is allowed to reuse a response when the response headers say it is fresh, or when the server validates that the stored copy is still current. The core tools are Cache-Control, ETag, Last-Modified, conditional requests, and 304 Not Modified.

This guide is for developers shipping APIs, static sites, Next.js apps, Cloudflare deployments, or any page where caching can either save seconds or cause a production incident. We will build the mental model, run real code, compare common headers, and cover the traps that only show up after deploy.

Quick answer

Use long caching only for versioned assets whose URLs change on deploy. Use revalidation for HTML and frequently changing API data. Use no-store for sensitive or one-time responses. If a stale page bug is hard to reproduce, inspect Cache-Control, ETag, Vary, and whether the request is hitting browser cache or CDN cache.

The HTTP caching model in one request flow

A browser cache has two broad choices. If the cached response is still fresh, it can reuse it without a network round trip. If the response is stale but has a validator, the browser can make a conditional request. The server then returns either a full 200 OK with new bytes, or a small 304 Not Modified saying “keep using the copy you already have.”

Fresh reuse

Cache-Control max-age has not expired, so no server request is needed.

Revalidation

The browser sends If-None-Match or If-Modified-Since after the cached response becomes stale.

Replacement

If the validator no longer matches, the server sends a new 200 response and the cache stores it.

The distinction matters because max-age=3600 and no-cache are almost opposites in practice. The first says “reuse this for an hour without asking.” The second says “you may store this, but ask before reusing it.” That name is brutally misleading, and it is responsible for a lot of production confusion.

Cache-Control directives you actually need

The modern caching contract lives mostly in the Cache-Control response header. It can target browsers, shared caches like CDNs, or both. A few directives cover almost every production case.

DirectiveMeaningUse when
max-age=secondsFresh lifetime for all caches.Browser and CDN can reuse unchanged public content.
s-maxage=secondsFresh lifetime for shared caches only.CDN should cache longer or shorter than browsers.
publicShared caches may store the response.Static files or public API responses.
privateOnly the end-user browser may store it.User-specific dashboards and account data.
no-cacheStore is allowed, but revalidate before reuse.HTML, API data that must be checked before showing.
no-storeDo not store the response anywhere.Auth flows, checkout, bank-like pages, one-time tokens.
immutableDo not revalidate while fresh.Hashed assets where URL changes with content.
stale-while-revalidate=secondsServe stale content while refreshing asynchronously.Low-risk public content where speed matters.

My default rule is boring because boring cache rules survive deploys: long cache only when the filename is versioned; revalidate HTML; keep user data private; use no-store only when storing would create a privacy or correctness problem.

ETag, Last-Modified, and 304 Not Modified

ETag and Last-Modified are validators. They do not make something fresh by themselves. They let the client ask whether a stale cached copy can still be used. The two request headers to watch are If-None-Match and If-Modified-Since.

# First request
GET /api/products HTTP/1.1

HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "products-v42"
Last-Modified: Wed, 01 Jul 2026 04:00:00 GMT
Content-Type: application/json

[{ "id": 1, "name": "Keyboard" }]

# Later revalidation
GET /api/products HTTP/1.1
If-None-Match: "products-v42"

HTTP/1.1 304 Not Modified
ETag: "products-v42"

A 304 response has no response body. The browser combines the 304 headers with the stored 200 response body and shows the cached content. This saves bandwidth and often latency, but it still requires a network request. If you expected no request at all, you wanted a fresh response with max-age, not revalidation.

Strong vs weak ETags

A strong ETag like "abc123" means byte-for-byte equivalence. A weak ETag like W/"abc123" means semantic equivalence. In everyday API work, a hash of the response body or a version field is usually enough. The mistake is generating a different ETag on every request, which makes every revalidation miss and turns caching into theater.

A runnable Express example with ETag and 304

This small server shows the mechanics without hiding behind framework magic. It returns a public JSON payload, computes a stable ETag from the response body, and returns 304 when the browser already has the current version.

import express from "express";
import crypto from "node:crypto";

const app = express();

function etagFor(value) {
  const body = JSON.stringify(value);
  const hash = crypto.createHash("sha256").update(body).digest("base64url");
  return { body, etag: `"${hash}"` };
}

app.get("/api/products", (req, res) => {
  const products = [
    { id: 1, name: "Keyboard", updatedAt: "2026-07-01T04:00:00Z" },
    { id: 2, name: "Mouse", updatedAt: "2026-07-01T04:00:00Z" }
  ];

  const { body, etag } = etagFor(products);

  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("ETag", etag);
  res.setHeader("Content-Type", "application/json; charset=utf-8");

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

  res.status(200).send(body);
});

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000");
});

Test it with cURL:

# First request: returns 200 and an ETag
curl -i http://localhost:3000/api/products

# Replace the ETag with the value from the first response
curl -i http://localhost:3000/api/products \
  -H 'If-None-Match: "paste-the-etag-here"'

In production you often do not hand-roll this because frameworks and CDNs help, but building it once makes the logs make sense. The key thing is that no-cache allows storage, the ETag validates the stored copy, and 304 avoids resending the body.

Recommended cache policies by resource type

There is no single best cache header. The right policy depends on whether the URL changes when content changes, whether the response is user-specific, and whether stale data is dangerous.

ResourceSuggested headerWhy
HTML documentCache-Control: no-cacheStore it, but revalidate before reuse so new deploys are discovered quickly.
Fingerprinted JS/CSSCache-Control: public, max-age=31536000, immutableThe URL changes when content changes, so a one-year cache is safe.
API user dataCache-Control: private, no-cacheBrowser may store it, shared caches may not, and every reuse must revalidate.
Public API catalogCache-Control: public, max-age=60, stale-while-revalidate=300Serve fast responses while refreshing in the background.
Downloads with fixed versionsCache-Control: public, max-age=31536000, immutableVersioned files are content-addressed in practice.
Admin or checkout pagesCache-Control: no-storeAvoid writing sensitive or one-time responses to any cache.

The modern app pitfall: cached HTML with hashed assets

Single-page apps and Next.js-style builds usually fingerprint JavaScript and CSS files. That is great for assets, but dangerous if you apply the same one-year cache to HTML. The HTML points to exact asset filenames. If a browser keeps old HTML too long while the deployment platform removes old chunks, users can get a blank page or 404s for /_next/static/....

Production pattern I trust

  • HTML: Cache-Control: no-cache plus ETag or Last-Modified.
  • Hashed assets: Cache-Control: public, max-age=31536000, immutable.
  • API data: choose private, no-cache, or short s-maxage based on whether the data is user-specific.
  • CDN rules: explicitly exclude admin, auth callback, preview, and checkout routes from shared caching.

The counterintuitive part is that the most aggressively cached part of your app should be the files users never navigate to directly. The HTML, which feels static, is often the coordination layer and should be revalidated.

Vary: the header that quietly breaks cache hit rates

Vary tells a cache which request headers affect the response. Vary: Accept-Encoding is normal because gzip and brotli bodies differ. Vary: Authorization or Vary: Cookie can be correct, but they often destroy shared-cache efficiency because every user or cookie state becomes a different cache entry.

Good candidate for CDN cache

A public docs page or catalog endpoint that does not depend on cookies, auth, or geography.

Bad candidate for shared cache

A dashboard endpoint that changes based on the logged-in user, subscription, cart, or experiment cookie.

How to debug HTTP caching in real projects

DevTools is useful, but it is also easy to fool yourself. The Network tab can disable browser cache while open. A CDN can return a HIT while your browser still revalidates. Service workers can intercept requests before the network layer. Debug in layers.

# See response headers only
curl -I https://example.com/

# Force a conditional request with an ETag
curl -i https://example.com/api/products \
  -H 'If-None-Match: "products-v42"'

# Bypass browser cache from the command line
curl -i https://example.com/ \
  -H 'Cache-Control: no-cache'

# Compare compressed variants
curl -I https://example.com/app.js -H 'Accept-Encoding: br'
curl -I https://example.com/app.js -H 'Accept-Encoding: gzip'
  1. Check the response Cache-Control header first; it overrides older Expires behavior in modern HTTP caches.
  2. Check whether the request includes If-None-Match or If-Modified-Since. Without a conditional request, 304 is impossible.
  3. Look for Vary. A response with Vary: Authorization or Vary: Cookie can split the cache into many variants.
  4. Compare browser cache and CDN cache separately. A CDN HIT does not guarantee the browser reused its own copy.
  5. Disable browser cache only while DevTools is open; otherwise you may debug a different world from real users.

When you find a strange status code, cross-check it in the HTTP Status Codes Reference. When you have a working cURL request, convert it into application code with the cURL Converter.

Common mistakes and the fix

Mistake: using no-cache to mean no storage

Use no-store when the response must not be stored. Use no-cache when storage is fine but reuse must be revalidated.

Mistake: caching personalized API responses as public

If the response depends on a user, token, cookie, or plan, assume private or no-store until proven otherwise. Shared caches are powerful and unforgiving.

Mistake: changing content without changing asset URLs

Long-lived cache only works when URLs are fingerprinted. If /app.js changes in place but is cached for a year, you have built a time capsule.

Mistake: treating CDN cache and browser cache as one thing

A CDN can cache a response for every visitor while each browser has its own local cache. Use response headers and CDN-specific headers to identify which layer served the content.

A short production checklist

  • HTML revalidates quickly and does not get a year-long cache.
  • Static assets include content hashes before you use immutable long caching.
  • User-specific API responses are private or not stored.
  • ETags are stable for unchanged content and change when the response body changes.
  • CDN cache rules exclude auth, admin, preview, and checkout routes.

Frequently asked questions

What is HTTP caching?

HTTP caching is the browser, proxy, or CDN practice of reusing a previous HTTP response instead of downloading the same resource again. Servers control it mainly with Cache-Control, ETag, Last-Modified, Expires, and Vary headers.

What is the difference between Cache-Control and ETag?

Cache-Control decides how long a response can be reused without asking the server. ETag is a validator used after the cached response becomes stale, so the browser can ask whether the cached copy is still current.

When does a server return 304 Not Modified?

A server returns 304 Not Modified when a conditional request proves that the client cached copy is still current. The common request headers are If-None-Match with an ETag or If-Modified-Since with a Last-Modified date.

Should HTML be cached?

HTML is usually cached conservatively because it points to the rest of the app. A common pattern is Cache-Control: no-cache plus ETag, which allows revalidation without forcing browsers to keep stale HTML for a long time.

What is a safe caching policy for fingerprinted assets?

For hashed assets like app.8f3a1c.js or styles.91ab.css, a common safe policy is Cache-Control: public, max-age=31536000, immutable. The filename changes when the content changes, so the old cached file can live for a year.

Authoritative references

For protocol details, read the source material rather than a summary:

Related tutorials

Z

Written by Zhisan

Independent Developer - Last updated July 2026