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.
| Directive | Meaning | Use when |
|---|---|---|
| max-age=seconds | Fresh lifetime for all caches. | Browser and CDN can reuse unchanged public content. |
| s-maxage=seconds | Fresh lifetime for shared caches only. | CDN should cache longer or shorter than browsers. |
| public | Shared caches may store the response. | Static files or public API responses. |
| private | Only the end-user browser may store it. | User-specific dashboards and account data. |
| no-cache | Store is allowed, but revalidate before reuse. | HTML, API data that must be checked before showing. |
| no-store | Do not store the response anywhere. | Auth flows, checkout, bank-like pages, one-time tokens. |
| immutable | Do not revalidate while fresh. | Hashed assets where URL changes with content. |
| stale-while-revalidate=seconds | Serve 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.
| Resource | Suggested header | Why |
|---|---|---|
| HTML document | Cache-Control: no-cache | Store it, but revalidate before reuse so new deploys are discovered quickly. |
| Fingerprinted JS/CSS | Cache-Control: public, max-age=31536000, immutable | The URL changes when content changes, so a one-year cache is safe. |
| API user data | Cache-Control: private, no-cache | Browser may store it, shared caches may not, and every reuse must revalidate. |
| Public API catalog | Cache-Control: public, max-age=60, stale-while-revalidate=300 | Serve fast responses while refreshing in the background. |
| Downloads with fixed versions | Cache-Control: public, max-age=31536000, immutable | Versioned files are content-addressed in practice. |
| Admin or checkout pages | Cache-Control: no-store | Avoid 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-cacheplus ETag or Last-Modified. - Hashed assets:
Cache-Control: public, max-age=31536000, immutable. - API data: choose
private,no-cache, or shorts-maxagebased 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'
- Check the response Cache-Control header first; it overrides older Expires behavior in modern HTTP caches.
- Check whether the request includes If-None-Match or If-Modified-Since. Without a conditional request, 304 is impossible.
- Look for Vary. A response with Vary: Authorization or Vary: Cookie can split the cache into many variants.
- Compare browser cache and CDN cache separately. A CDN HIT does not guarantee the browser reused its own copy.
- 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.
Try These Tools
Authoritative references
For protocol details, read the source material rather than a summary:
- RFC 9111: HTTP Caching defines the current HTTP caching semantics.
- MDN HTTP caching guide is the most readable reference for browser-focused behavior.
- web.dev HTTP cache article explains practical browser caching and validation behavior.
Related tutorials
HTTP Status Codes Guide
Understand 304, 412, 428, redirects, and error responses.
HTTP API Best Practices
Design APIs with status codes, errors, versioning, and caching in mind.
API Integration Examples
Handle retries, conditional requests, pagination, and response parsing.
CORS Preflight Fix
Debug another common browser/network layer problem.
Written by Zhisan
Independent Developer - Last updated July 2026