HTTP caching is a mechanism in the HTTP protocol that lets browsers, CDNs and proxy servers store copies of responses and reuse them for later requests without contacting the origin server. It is the single most important performance technique on the web. A correctly cached static asset (image, CSS, JS bundle) takes 1 to 10 milliseconds to load from the browser cache versus 100 to 1000 milliseconds from the origin. HTTP caching is controlled by response headers defined in RFC 7234 (June 2014) and the newer RFC 9111 (June 2022): Cache-Control (the primary modern header), Expires (legacy), ETag and Last-Modified (validators), and Vary (cache key modifier). Used correctly, HTTP caching reduces Largest Contentful Paint (LCP), saves bandwidth (Google estimates 60 to 80 percent of typical website bandwidth could be cached), reduces origin server load and lowers carbon footprint. Used incorrectly, it causes stale content, broken deploys and infinite browser tab reloads.
How does HTTP caching work?
When a browser requests a resource, the response can include cache instructions. The browser stores the response locally. On the next request for the same URL, the browser checks the cache:
- If the cached copy is still fresh (within its TTL), the browser uses it directly without any network request. This is called a cache hit.
- If the cached copy is stale but has a validator (ETag or Last-Modified), the browser sends a conditional request with
If-None-MatchorIf-Modified-Since. The server returns304 Not Modifiedwith no body if the resource has not changed, saving bandwidth. - If there is no validator and the copy is stale, the browser refetches the full resource (cache miss).
Between the browser and the origin there are usually one or more shared caches (CDN edge nodes, corporate proxies). The same rules apply at each layer.
What does Cache-Control do?
Cache-Control is the primary header that controls caching behaviour. It accepts a comma separated list of directives:
| Directive | Meaning |
|---|---|
max-age=N | The response is fresh for N seconds. |
s-maxage=N | Same as max-age but only for shared caches (CDN, proxy). Overrides max-age there. |
public | Any cache may store the response (browser and shared). |
private | Only the browser may store, never shared caches. Use for personalized content. |
no-cache | Cache may store but must revalidate with the origin before reuse on every request. |
no-store | Never store the response anywhere. For truly sensitive data (banking, health). |
must-revalidate | Once stale, the cache must check with origin and cannot serve stale on errors. |
stale-while-revalidate=N | After expiry, serve stale for N seconds while revalidating in background. Big UX win. |
stale-if-error=N | If origin returns 5xx, serve stale for up to N seconds. |
immutable | The response will never change. The browser skips even validation on reload. |
Practical examples:
# Static asset that is fingerprinted (app.abc123.js)
Cache-Control: public, max-age=31536000, immutable
# HTML page that should always be fresh but tolerates a quick revalidate
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
# Personalized account page
Cache-Control: private, no-cache
# Login form
Cache-Control: no-storeWhat is an ETag?
An ETag (Entity Tag) is an opaque identifier the server attaches to a response to identify a specific version of the resource. When the client sends a conditional request, it includes the ETag value in the If-None-Match header. If the current ETag still matches, the server returns 304 Not Modified with no body, saving bandwidth.
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Type: image/jpeg
Content-Length: 89342
[image data]
# Later request
GET /image.jpg HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# Response
HTTP/1.1 304 Not ModifiedETags can be strong (byte for byte identical) or weak (semantically equivalent, prefixed with W/). Strong ETags are usually a hash of the content (SHA1, MD5, xxHash). Weak ETags are useful when minor formatting differences should not invalidate the cache.
What is Last-Modified?
Last-Modified is a timestamp validator with 1 second resolution. The client sends it back as If-Modified-Since:
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
# Later
GET /article HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT
# Response
HTTP/1.1 304 Not ModifiedCompared to ETag, Last-Modified is simpler but limited: anything changing more often than once per second cannot be distinguished. For database backed content (a WordPress post updated twice in the same minute) ETag is the better choice.
What is the Vary header?
Vary tells the cache that the response depends on certain request headers. The cache must store separate copies per unique combination of those headers. Common examples:
Vary: Accept-Encodingseparates gzip, brotli and uncompressed copies.Vary: Accept-Languageseparates English, German, French versions.Vary: User-Agentseparates desktop and mobile HTML. Avoid this, it causes cache explosion because every browser version creates a new key.Vary: Cookieeffectively disables shared caching for any response, because every user has unique cookies. Almost always wrong.
Browser cache vs CDN cache vs proxy cache
| Cache layer | Location | Honored by |
|---|---|---|
| Browser cache | Local disk or memory of the user device | max-age, no-cache, no-store, ETag, Last-Modified |
| CDN edge cache | CDN PoP near the user (Cloudflare, Fastly, Akamai, CloudFront) | s-maxage, public, stale-while-revalidate, Surrogate-Key, custom Cache-Tag |
| Corporate proxy | Inside enterprise networks | public, private (rarely used today) |
| Service Worker cache | Inside the page via JavaScript | Custom logic, ignores HTTP headers unless you tell it to |
Cache busting and fingerprinting
Long max-age (a year) plus immutable is great for performance, but how do you deploy a new version? The answer is fingerprinting: build tools (webpack, Vite, esbuild, Turbopack, Rollup) include a content hash in the filename. app.css becomes app.a1b2c3d4.css. When you change the file, the hash changes, the URL changes, the cache miss is automatic. HTML pages reference these fingerprinted assets, so HTML itself should NOT have a long cache. Pattern:
- HTML:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=86400 - JS/CSS/fonts/images with hash:
Cache-Control: public, max-age=31536000, immutable
HTTP caching in WordPress
WordPress core sends very weak cache headers out of the box. Best practice is to add headers via a caching plugin or at the web server level. Options:
- WP Rocket: paid, sets browser caching headers, serves a precomputed HTML cache, supports stale-while-revalidate.
- LiteSpeed Cache: free, integrates with LiteSpeed and OpenLiteSpeed servers, supports ESI for partial caching, also works as a CDN cache hook.
- W3 Total Cache: free, mature, supports browser cache rules, object cache (Redis, Memcached) and CDN integration.
- WP Super Cache: free, simple full page cache by Automattic.
- Cloudflare APO (Automatic Platform Optimization): cache full HTML at the edge for $5/month, deals with WordPress logged in users via cookies.
nginx config example for WordPress:
location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
try_files $uri $uri/ /index.php?$args;
add_header Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400";
}Common HTTP caching mistakes
- Setting long max-age on HTML: users see old content after deploys with no way to recover except hard reload.
- Forgetting to set headers on static assets: every page load refetches the same logo, slowing things down.
- Using
Vary: User-Agent: causes cache key explosion at the CDN. - Mixing
Set-CookiewithCache-Control: public: shared caches sometimes store the cookie and leak it to other users. - Trusting
Pragma: no-cache: this is an HTTP/1.0 request header, ignored by modern servers and many CDNs. Use Cache-Control instead. - Not setting headers at all: browsers fall back to heuristic caching (10 percent of Last-Modified age) which is unpredictable.
- Cache poisoning: if the cache key does not include all relevant headers, an attacker can poison the cache with malicious content. Always use
Varyfor any request header that influences the response.
How do I test HTTP caching?
- Browser DevTools Network tab: open the resource and check Size column. (memory cache) or (disk cache) means hit. 304 means revalidated. Look at Response Headers.
- curl:
curl -I https://example.com/style.cssprints headers. - WebPageTest: shows cache scores per asset and grades the configuration.
- Lighthouse: audits "Uses efficient cache policy on static assets".
- RedBot (
redbot.org): a free Mark Nottingham tool that analyses HTTP caching headers and warns about subtle mistakes.
What about HTTP/2 and HTTP/3?
HTTP/2 (RFC 7540, May 2015) and HTTP/3 (RFC 9114, June 2022) keep the same caching semantics but add Server Push (now deprecated in Chrome since 2022 in favor of Early Hints, RFC 8297). Early Hints (103 Early Hints) let servers preload critical resources before the main response arrives, complementing the cache rather than replacing it.
How does InspectWP help with HTTP caching?
InspectWP inspects the response headers of the HTML and all linked assets of every analyzed URL. The report flags missing Cache-Control headers, overly short max-age values on fingerprinted assets, missing ETag or Last-Modified validators and dangerous Vary combinations.