HTTP Cache-Control Headers: Complete Caching Guide
The Cache-Control header is the main tool for HTTP caching control. A correct setup reduces server load, accelerates the site for users, and improves PageSpeed анализ. A wrong setup leads to "stuck" content, missing updates, and SEO problems.
This guide covers every Cache-Control directive, relationships with ETag, Last-Modified, and Vary, strategies for different content types (static, HTML, API документацию), and real nginx and Apache examples.
What Cache-Control Is
Per RFC 9111, Cache-Control governs the behavior of caches (browser, CDN, proxy). It is the standard since 1999 (HTTP/1.1), replacing the legacy Expires and Pragma headers.
Key Directives
max-age=N
Cache lifetime in seconds. max-age=3600 = cache valid for 1 hour.
s-maxage=N
Like max-age, but only for shared cache (CDN, reverse proxy), ignored by browsers.
public
Allowed for both shared (CDN) and private (browser) caches.
private
Browser only. Forbids CDN caching (for authenticated content).
no-cache
Does NOT mean "do not cache"! It means "always revalidate with origin via ETag/If-Modified-Since before using cache." If 304 is returned — use cache.
no-store
This one truly means "do not cache at all." No browser, CDN, or proxy should store the response.
must-revalidate
Forbids using stale cache without revalidation. Useful for financial data.
immutable
A promise: content will never change. Browser does not revalidate even on reload.
stale-while-revalidate=N
Allows serving stale cache for N seconds while revalidation happens in background. Instant response + fresh content on the next request.
stale-if-error=N
If origin fails, serve stale cache for N seconds. Downtime protection.
Common Patterns
Hashed static assets
Cache-Control: public, max-age=31536000, immutable
# 1 year, immutable — webpack bundle app.a3f2b.js will not changeHTML pages
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
# Browser: always revalidates
# CDN: caches 1 hour
# After expiry — stale-while-revalidate for a dayAPI endpoints (dynamic)
Cache-Control: private, max-age=60
# Private cache 60 sec, CDN does not cacheAuthenticated content
Cache-Control: private, no-cache
Vary: Authorization, CookieSensitive data (banking, passwords)
Cache-Control: no-store
Pragma: no-cachenginx Configuration
server {
# Hashed static — long immutable cache
location ~* \.(css|js|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Unhashed images — moderate cache
location ~* \.(png|jpg|gif)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# HTML — minimal cache, stale-while-revalidate
location ~* \.html$ {
expires 0;
add_header Cache-Control "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400";
}
# API — private, short
location /api/ {
add_header Cache-Control "private, max-age=60";
proxy_pass http://backend;
}
# Sensitive
location /api/account {
add_header Cache-Control "no-store";
}
}Apache Configuration (.htaccess)
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 7 days"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400"
</FilesMatch>
</IfModule>ETag and Last-Modified: Revalidation
When cache expires, the browser issues a conditional request:
GET /page.html
If-None-Match: "a3f2b"
If-Modified-Since: Wed, 01 Jan 2026 12:00:00 GMTServer responds with 304 Not Modified (empty body) or 200 with new content. 304 is cheaper than a full transfer.
# nginx auto-emits ETag for static
etag on;
# PHP
$etag = md5_file($path);
header("ETag: \"{$etag}\"");
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === "\"{$etag}\"") {
http_response_code(304);
exit;
}Vary — Accounting for Representations
Vary: Accept-Encoding
# Separate caches per gzip/br/identity
Vary: Accept-Language
# Separate caches per ru/en versions
Vary: Cookie
# Breaks shared cache — use only when necessaryCache-Busting Strategies
- Fingerprinting (hash in filename) —
app.a3f2b.js. Most reliable. - Query string —
app.js?v=2. Works, but some CDNs ignore query. - Versioning path —
/v2/app.js. Full invalidation on release. - ETag-based — only for HTML, not for static assets.
CDN Specifics
- Cloudflare honors Cache-Control but has its own Edge TTL in Page Rules. s-maxage is sometimes ignored — verify via
CF-Cache-Statusheader. - Fastly uses
surrogate-controlfor CDN-only TTL. - AWS CloudFront honors s-maxage.
Verifying Caching
Use the HTTP Header Checker — it displays Cache-Control, ETag, Last-Modified, Age, and CF-Cache-Status in one place. Our full caching guide covers advanced examples.
# Terminal check
curl -I https://example.com/app.js
# With revalidation
curl -H 'If-None-Match: "a3f2b"' https://example.com/app.js
# expect HTTP/2 304Anti-patterns
- no-cache for hashed static assets — lose perf for nothing.
- max-age=0 without ETag — full retransmission every request.
- public for authenticated content — leak via CDN.
- Vary: * — completely breaks cache.
- Cache-Control without units (
max-age=1 year) — only seconds are valid.
Frequently Asked Questions
Q: Why does no-cache still cache?
A: no-cache ≠ no-store. no-cache allows storing but requires revalidation. For full disable — no-store.
Q: max-age or Expires — which is better?
A: max-age. Expires is a legacy HTTP/1.0 header that relies on correct server time. Cache-Control takes precedence.
Q: Cache stuck after site update?
A: Use fingerprinting — a new hash in the filename gives a new URL, and the browser loads the fresh version.
Q: Does caching affect SEO?
A: Indirectly via Core Web Vitals. Correct caching reduces TTFB and improves LCP.
Conclusion
Cache-Control is powerful but requires understanding of directives. Hashed static = immutable for a year. HTML = short max-age + stale-while-revalidate. Authenticated = private or no-store. Always verify real headers via the Enterno.io HTTP Checker after changes.
Check your website right now
Check now →