max-age, ETag, and service workers explained hands-on
Misconfigured Cache-Control headers force browsers to reload the same files on every page view, costing noticeable load time and unnecessary server load. This article explains the key directives, ETag validation, cache busting, and how the browser cache, shared cache, and service worker cache interact, using concrete Magento examples.
Table of Contents
- 1. Why Cache-Control headers determine performance and server load
- 2. Cache-Control directives in detail: max-age, no-cache, no-store, immutable
- 3. ETag and Last-Modified: validation requests instead of retransmission
- 4. Cache busting: versioned filenames vs. query strings
- 5. stale-while-revalidate: serving stale content, refreshing in the background
- 6. Browser cache, shared cache, and service worker cache working together
- 7. Magento static assets: setting Cache-Control headers correctly
- 8. Debugging cache headers: curl, DevTools, and common misconfigurations
- 9. Cache-Control directives compared by asset type
- 10. Summary
- 11. FAQ
1. Why Cache-Control headers determine performance and server load
Cache-Control headers decide whether a request ever leaves the network at all or is served directly from the browser's local storage. Without correctly set headers, the browser reloads the same CSS, JavaScript, and image files on every navigation, even when nothing about the content has changed. For a typical Magento store with 40 to 60 static assets per page, this adds up to several hundred milliseconds of extra load time on every page change, because every single file triggers its own round trip to the server or at least a validation request.
The decisive difference from rendering optimizations: caching takes effect before the browser processes the first byte at all. A correctly configured Cache-Control header does not just save bandwidth, it eliminates the latency entirely, because the request never leaves the network stack in the first place. For Magento stores with an international customer base, where every request travels several hundred milliseconds of latency to the nearest data center, HTTP caching is therefore the most effective and simultaneously cheapest lever in the entire performance stack.
2. Cache-Control directives in detail: max-age, no-cache, no-store, immutable
max-age=<seconds> defines how long a resource is considered fresh without the browser contacting the server at all. For versioned, immutable assets such as hashed CSS or JS files, max-age=31536000 (one year, the practical maximum that browsers respect) is the standard value. no-cache, despite its name, does not mean "do not cache" but rather "revalidate before every use": the browser is allowed to store the resource but must issue a validation request before every use.
no-store is the only directive that forbids storage entirely, neither in the browser cache nor in any intermediate cache, and belongs on responses carrying sensitive data such as checkout forms or account statements. immutable signals to the browser that the content is guaranteed not to change for the entire max-age lifetime, which means even revalidation requests on a reload are skipped, a behavior that Firefox and Safari honor while Chrome only honors it on HTTPS resources. public and private additionally control whether shared caches such as Varnish or a CDN are allowed to store the response at all.
3. ETag and Last-Modified: validation requests instead of retransmission
When a resource is considered expired or no-cache is set, the browser sends a validation request instead of a full request: If-None-Match with the previously received ETag value, or If-Modified-Since with the Last-Modified timestamp. If the server responds with 304 Not Modified, it transfers no response body, only headers, which shrinks the data volume to a few hundred bytes compared with a full 200 OK response.
ETags come in two variants: strong validators (ETag: "abc123") guarantee byte-identical content, while weak validators (ETag: W/"abc123") allow semantically equivalent but not byte-identical responses, for example server-side compressed variants of the same file. Last-Modified only has second-level precision and is therefore less suited for assets that can change multiple times per second. In practice, both are combined: ETag as the primary validator, Last-Modified as a fallback for clients or proxies that do not support ETags.
4. Cache busting: versioned filenames vs. query strings
Cache busting solves a fundamental problem: an asset with a long max-age must still be reliably updatable as soon as its content changes. Two approaches compete here: query-string versioning (style.css?v=42) and versioning via the filename itself (style.a1b2c3d4.css). Query strings look simpler but have a decisive drawback: older HTTP/1.0 proxies and some CDN configurations ignore the query string when caching and treat every URL as identical regardless of the ?v parameter, which causes stale content to be served despite a changed version.
Versioned filenames with a content hash in the name itself sidestep this problem completely, because the URL actually changes on every content change and therefore represents a completely new resource for every cache, whether browser, CDN, or proxy. This combination of hash-based filename and Cache-Control: max-age=31536000, immutable is the gold standard for static assets: cache for as long as technically possible, because any change automatically produces a new URL anyway.
# Inspect response headers without downloading the body
curl -I https://mironsoft.de/static/frontend/Mironsoft/default/en_US/css/styles-m.min.css
# HTTP/2 200
# cache-control: public, max-age=31536000, immutable
# etag: "a1b2c3d4e5f6"
# last-modified: Wed, 08 Jul 2026 09:12:03 GMT
# Verify conditional revalidation returns 304 with no body
curl -I -H 'If-None-Match: "a1b2c3d4e5f6"' \
https://mironsoft.de/media/catalog/product/cache/1/image/example.jpg
# HTTP/2 304
# cache-control: public, max-age=604800
# Loop through a list of assets and print only the cache-control header
for url in $(cat asset-urls.txt); do
echo "$url"
curl -sI "$url" | grep -i 'cache-control\|etag'
done
5. stale-while-revalidate: serving stale content, refreshing in the background
stale-while-revalidate=<seconds> extends max-age with a time window during which the browser serves an already expired resource immediately from the cache while simultaneously requesting a fresh version in the background. The user therefore never sees a wait for revalidation, instead the current request still gets the old state while the next request already receives the updated version.
The directive is particularly suited to content that changes frequently but non-critically, such as category-page fragments or price-list widgets, where a display that is a few minutes stale is acceptable. A typical header looks like this: Cache-Control: max-age=60, stale-while-revalidate=3600. Important: older Varnish versions do not support the directive without an extension, while modern CDNs such as Fastly, Cloudflare, or Akamai implement it natively.
<!-- Hashed filename: served with far-future max-age, safe because the URL itself changes on every deploy -->
<link rel="stylesheet" href="{{$block->getViewFileUrl('css/styles-m.a1b2c3d4.min.css')}}">
<!-- Query-string versioning: some CDNs and legacy proxies ignore the query string when caching -->
<link rel="stylesheet" href="{{$block->getViewFileUrl('css/styles-m.min.css')}}?v=<?= $block->escapeHtmlAttr($block->getModuleVersion()) ?>">
<!-- Preload the hashed critical CSS with matching Cache-Control expectations -->
<link rel="preload" as="style" href="{{$block->getViewFileUrl('css/critical.a1b2c3d4.min.css')}}">
6. Browser cache, shared cache, and service worker cache working together
Three independent cache layers sit between server and user, and each one interprets Cache-Control headers slightly differently. The browser cache (disk cache and memory cache) respects max-age as well as private/public. Shared caches such as Varnish, a CDN, or a reverse proxy serve multiple users simultaneously and instead prefer s-maxage when set, which applies specifically to these intermediate caches and always ignores private resources.
The third layer, the service worker cache via the Cache API, no longer follows HTTP caching rules at all but is controlled entirely programmatically through JavaScript and sits in the fetch event handler even before the browser's HTTP cache. The Vary header is critical for the correctness of all three layers: without Vary: Accept-Encoding, a shared cache could serve a gzip-compressed response to a client that does not support compression.
// service-worker.js: stale-while-revalidate for static assets
const CACHE_NAME = 'static-v3';
self.addEventListener('fetch', (event) => {
if (event.request.destination !== 'style' && event.request.destination !== 'script') {
return;
}
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request);
// Kick off a background revalidation regardless of cache hit
const networkFetch = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
// Serve the stale response immediately if present, network otherwise
return cached || networkFetch;
})
);
});
7. Magento static assets: setting Cache-Control headers correctly
Magento's setup:static-content:deploy generates a versioned path for every static file, which makes it safe to set max-age=31536000, immutable: every content change automatically produces a new deploy folder and therefore a new URL. These headers belong in nginx.conf.sample or in .htaccess for pub/static/, not in PHP, so that Nginx or Apache serves the response directly without ever starting the PHP process.
A different rule applies to pub/media/catalog/product/ images, since product images can be replaced at any time via the admin without the URL changing: here a shorter max-age of a few days combined with ETag validation instead of immutable is recommended. The built-in Full Page Cache and a front-facing Varnish additionally set their own headers such as X-Magento-Cache-Debug, which must not be confused with the Cache-Control headers for static assets, because they control different cache layers.
<IfModule mod_expires.c>
ExpiresActive On
<!-- Hashed static assets: cache for one year, no revalidation needed -->
<FilesMatch "\.(css|js)$">
ExpiresDefault "access plus 1 year"
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<!-- Product images: shorter TTL, still validated via ETag -->
<FilesMatch "\.(jpg|jpeg|png|webp|avif)$">
ExpiresDefault "access plus 7 days"
Header set Cache-Control "public, max-age=604800"
FileETag MTime Size
</FilesMatch>
</IfModule>
<IfModule mod_headers.c>
<!-- Never let a shared cache or CDN store checkout responses -->
<LocationMatch "^/checkout">
Header set Cache-Control "no-store"
</LocationMatch>
</IfModule>
8. Debugging cache headers: curl, DevTools, and common misconfigurations
curl -I <url> shows the complete response headers including Cache-Control, ETag, and Last-Modified without downloading the body, making it the fastest way to verify a header configuration directly. A second call with curl -H "If-None-Match: <etag>" <url> against the same URL should return a 304 status with no body if validation works correctly; if a 200 with a full body appears instead, the server is ignoring the validator.
In the Chrome DevTools network tab, the Size column shows "(memory cache)", "(disk cache)", or "(ServiceWorker)" for requests that never triggered a network request, as well as the actual transfer in kilobytes for everything else. The most common misconfiguration in practice: a CDN or reverse proxy overwrites the Cache-Control headers set by the origin server with its own default values, often no-cache for everything, which renders the entire backend configuration useless without any error being visible in the code.
{
"cachePolicy": "mironsoft-static-assets",
"rules": [
{
"pathPattern": "/static/frontend/*/*/*/css/*.min.css",
"cacheControl": "public, max-age=31536000, immutable",
"respectOriginHeaders": false
},
{
"pathPattern": "/media/catalog/product/*",
"cacheControl": "public, max-age=604800",
"respectOriginHeaders": true,
"staleWhileRevalidate": 86400
},
{
"pathPattern": "/checkout/*",
"cacheControl": "no-store",
"bypassCache": true
}
]
}
9. Cache-Control directives compared by asset type
Every asset type in a Magento store needs its own cache strategy, depending on how often the content changes and how critical stale data would be. The following overview summarizes the recommended Cache-Control directives by asset type.
| Asset type | Recommended directive | Common mistake | Correct |
|---|---|---|---|
| Versioned CSS/JS (hash in name) | max-age=31536000, immutable | max-age=3600 without a hash | One year + immutable |
| Product images (pub/media) | max-age=604800 + ETag | immutable without versioning | 7 days + validator |
| Dynamic HTML | no-cache or private, max-age=0 | public, max-age=86400 | Force revalidation |
| Checkout / customer data | no-store | max-age set | Never store |
| API fragments (category list) | max-age=60, stale-while-revalidate=3600 | Hard revalidation without SWR | SWR in the background |
In practice, the cache layers often depend on one another: a misconfigured directive on a single asset type affects both the browser cache and the shared cache in front of it. Consistently applying the recommendations in the table and regularly verifying them with curl and DevTools avoids both unnecessary server load and stale delivery of sensitive content.
Mironsoft
Performance engineering, HTTP caching, and Magento/Hyvä optimization for fast stores
Ready to configure Cache-Control headers professionally?
We analyze your Magento store's cache headers, identify misconfigurations across static assets, Varnish, and CDN, and implement a clean cache strategy for the browser, shared, and service worker cache layers.
Cache header audit
Analysis of all Cache-Control, ETag, and Vary headers across the entire delivery chain
Static asset optimization
Hash-based cache busting and maximum max-age values for Magento deploys
Service worker strategy
stale-while-revalidate and offline capability for critical store areas
10. Summary
Browser caching and Cache-Control headers solve a core problem: repeated requests should be avoided as far as possible without serving stale content. Versioned, immutable assets should be cached for as long as technically possible with max-age=31536000, immutable and a hash in the filename. Dynamic and sensitive content such as checkout pages, on the other hand, should consistently use no-store or at least no-cache with ETag validation, so that stale or incorrect data is never served from a cache.
The decisive difference between fast and slow delivery rarely lies in a single setting but in how all three cache layers work together: browser cache, shared cache, and, where useful, service worker cache with stale-while-revalidate. Regularly verifying with curl and the browser DevTools ensures that CDN or proxy configurations do not silently overwrite the headers set in the backend.
Configuring Cache-Control headers - the essentials at a glance
max-age & immutable
For versioned, hashed assets: max-age=31536000, immutable as the default for static files.
ETag & Last-Modified
Validation requests instead of retransmission: 304 responses save bandwidth for unchanged content.
Cache busting via hash
Content hash in the filename instead of a query string, because proxies and CDNs treat query strings inconsistently.
Three cache layers
Coordinate the browser, shared, and service worker cache correctly with the Vary header and stale-while-revalidate.