Configuring Browser Caching and Cache-Control Headers Correctly
AI generated
60fps
ms
Performance · Caching · HTTP Headers · Magento 2
Configuring Browser Caching and Cache-Control Headers Correctly
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.

13 min read Cache-Control · ETag · Service Worker Magento 2.4.8 · Hyvä Theme · CDN

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.

11. FAQ: Browser Caching and Cache-Control Headers

1What is the difference between no-cache and no-store?
no-cache allows storage but requires validation before every use. no-store forbids storage entirely and belongs on sensitive responses such as checkout pages.
2What exactly does max-age mean and how is it calculated?
Seconds from the moment of receipt during which a resource is considered fresh. For hashed assets, max-age=31536000 (one year) is the practical maximum value.
3When should I use immutable?
Only for assets whose URL is guaranteed to change on every change, e.g. via a content hash in the filename. Then even revalidation on reload is skipped.
4How do strong and weak ETags differ?
Strong ETags guarantee byte-identical content, weak ETags (W/ prefix) allow semantically equivalent but not necessarily identical responses.
5What is the difference between cache busting via query string and via filename?
Query strings are ignored by some proxies/CDNs when caching. Versioned filenames change the URL itself and therefore work reliably across every cache layer.
6How does stale-while-revalidate work exactly?
After max-age expires, the browser immediately serves the stale resource and updates in the background, visible only on the next request.
7What is the difference between max-age and s-maxage?
max-age applies to all caches including the browser. s-maxage applies only to shared caches like Varnish/CDN and overrides max-age there.
8How does a service worker cache differently from the browser HTTP cache?
Fully controlled programmatically via the Cache API, independent of HTTP rules, intercepting requests in the fetch event handler before the browser HTTP cache.
9Which Cache-Control headers should Magento set for pub/static?
public, max-age=31536000, immutable for versioned deploy files, set in the web server configuration, not in PHP.
10How do I find cache header misconfigurations?
curl -I shows the served headers without the body, a second call with If-None-Match checks 304 validation. DevTools additionally shows the cache source of every request.