Browsers and CDNs cache resources to avoid re-downloading them on every visit. The HTTP headers your server sends determine what gets cached, for how long, and under what conditions. Configured well, returning visitors load your site in well under a second; configured poorly, every visit re-downloads megabytes that haven't changed.
This guide covers the headers that matter, the typical strategy for different resource types, and the common mistakes that quietly defeat caching.
The Headers That Matter
Cache-Control
The primary header. Tells caches (browsers, CDNs, intermediate proxies) how to handle the response.
Cache-Control: public, max-age=31536000, immutable
Common directives:
public— any cache (browser, CDN, proxy) may store the response.private— only the user's browser may cache. CDNs and shared caches must not.no-cache— caches must revalidate with the origin before using a cached copy. (Despite the name, "no-cache" doesn't mean "don't cache".)no-store— actually don't cache. The response must not be stored anywhere.max-age=N— cache is fresh for N seconds. After that, must revalidate or refetch.s-maxage=N— like max-age, but only applies to shared caches (CDNs).immutable— promises the resource will never change. Browsers won't even revalidate when the user reloads.must-revalidate— once stale, must revalidate before reusing.
ETag
A version identifier for the resource. Hashed from the content, or a sequence number, or anything else that changes when the resource changes.
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
When a cache has a stale copy, it asks the origin: "do you have version
Last-Modified
An older alternative to ETag. The date the resource was last changed.
Last-Modified: Wed, 27 Apr 2026 06:00:00 GMT
Caches send If-Modified-Since with the date they have; the origin returns 304 if nothing newer.
ETag is more precise (handles changes within the same second, regenerated content with same modify time). Last-Modified is simpler. Most modern stacks send both.
Vary
Tells caches that the response varies based on certain request headers.
Vary: Accept-Encoding
Means: the cached response depends on the value of Accept-Encoding. The cache must store separate copies for gzip and brotli clients. Without this, a brotli-compressed response might be served to a non-brotli client and break.
The Strategy: Cache by Resource Type
Different resources need different cache strategies:
Static assets with content-based filenames
JavaScript bundles, CSS files, images with hashed filenames like main.a3f5b7.js. The filename changes whenever the content changes (typical of webpack, Vite, etc. build outputs).
Cache-Control: public, max-age=31536000, immutable
One year. Immutable. Browsers won't even check for updates — when the file changes, you ship a different filename.
Static assets without versioned filenames
Images uploaded via CMS, PDF documents, anything where the URL is stable but the content might change.
Cache-Control: public, max-age=86400, must-revalidate
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
One day. Must revalidate. ETag lets revalidation be cheap — origin returns 304 if unchanged.
HTML documents
The page itself. Content changes; you want users to see new versions but not constantly re-download unchanged pages.
Cache-Control: public, max-age=300, s-maxage=86400, must-revalidate
Browsers cache for 5 minutes (returning visitors within that window get instant loads). CDNs cache for 1 day (so even first-time visitors get fast responses from edge nodes). Must-revalidate ensures stale content gets refreshed.
API responses
Personalised or rapidly-changing data — cart contents, user profiles, dynamic search results.
Cache-Control: private, no-store
Don't cache. Every request hits the origin. Appropriate for anything user-specific.
Or, for cacheable but personalised:
Cache-Control: private, max-age=60
Browser caches for 60 seconds; CDNs don't cache (private). Returning user gets instant response within the minute.
Authenticated pages
Logged-in dashboards, account pages.
Cache-Control: private, no-cache
Browser may cache, but must revalidate (so logout state is respected immediately). CDNs don't cache.
Common Mistakes
1. No caching headers at all
Server doesn't send Cache-Control. Browsers apply heuristics (typically 10% of the time since Last-Modified). Behaviour is unpredictable. Always send explicit headers.
2. no-cache on static assets
"no-cache" means revalidate with origin every time, not "don't cache". For static assets, this still means an HTTP round-trip per request even when nothing has changed. Use max-age for actual caching.
3. Long max-age on HTML
HTML documents cached for 1 year. Now content updates take a year to reach returning users. Use short max-age (300-3600s) on HTML; rely on revalidation.
4. Missing Vary: Accept-Encoding
Server compresses with brotli for clients that support it, gzip for others. Without Vary, intermediate caches may serve brotli content to gzip clients (or vice versa) and break things.
5. Caching personalised content as public
"Welcome back, Sarah!" cached publicly on the CDN. Now everyone sees Sarah's name. Always use private for user-specific content.
6. Forgetting CDN purge after deploys
HTML cached at the CDN for 1 day. You deploy a fix at 3pm. Nobody sees it until 24 hours later. Either set short s-maxage or purge the CDN cache as part of deployment.
7. Inconsistent ETags
Server generates a different ETag each request (timestamp-based, or randomly). Caches always think the resource has changed; revalidation always returns 200, never 304. Fix: ETags must be content-based and stable.
Service Workers and Custom Caching
For modern web apps, service workers offer programmable caching beyond what HTTP headers can express:
- Cache-first for static assets — instant load, with background update.
- Stale-while-revalidate for HTML — show cached version immediately, fetch update in background.
- Network-first for dynamic data — try network, fall back to cache on failure.
- Custom strategies per route, per resource type, per user state.
Frameworks like Workbox simplify service worker caching. For sites that need offline support or app-like performance, service workers are the next layer beyond HTTP caching.
Verifying Your Caching
Three checks:
- curl with -I to see headers:
curl -sI https://example.com/style.css | grep -i cache. - Chrome DevTools Network panel — look at the "Size" column. "(disk cache)" or "(memory cache)" means the request was served from cache without touching the network.
- Site Speed Check reports caching headers and flags issues.
For deeper debugging, look at the Cache-Control, ETag, and Vary headers in DevTools and verify they match what you intended.
The Practical Configuration
For most sites, this gets you 95% of the way:
- Versioned static assets (JS, CSS, hashed images):
Cache-Control: public, max-age=31536000, immutable - Unversioned static assets (uploads, PDFs, fonts without hash):
Cache-Control: public, max-age=86400, must-revalidate - HTML:
Cache-Control: public, max-age=300, s-maxage=86400, must-revalidate - API / personalised:
Cache-Control: private, no-storeorprivate, max-age=60 - Vary: Accept-Encoding on every response.
Set these in nginx, Apache, or your CDN of choice. Test with curl. Verify with Site Speed Check. Returning visitors will load your site near-instantly, and your origin server will see a fraction of the request volume.