Server & Hosting Performance

Browser caching explained: Cache-Control, validators and safe cache busting

Long lifetimes work for versioned assets; HTML and private responses need different rules.

A browser reusing stacked cached resources instead of requesting a distant server
Illustration: a reusable local response avoids another network transfer.

Browser caching lets a browser reuse an HTTP response instead of downloading the same resource again. Use long freshness lifetimes for public files whose URLs change with their contents, require validation for HTML that must stay current, and explicitly protect personalized responses. An efficient cache policy is a contract about reuse and updates, not a single long max-age applied to every URL.

Understand fresh, stale and validated responses

A cache stores a response, including its body and relevant headers. While the response is fresh, the browser can reuse it without asking the server. Once stale, it generally needs a network interaction before reuse unless a policy permits stale delivery. Expiry does not mean the stored file immediately disappears; it changes the conditions under which the copy can be used.

Validation asks whether the stored representation still matches the current one. If it does, the server can return 304 Not Modified without sending the body again. That saves transfer bytes but still involves a request and response. A fresh local hit avoids that round trip, so a validated cache hit and a network-free cache hit are not equivalent performance outcomes.

The browser's private cache is different from a shared proxy or CDN cache. A response safe for reuse by one user may be unsafe to reuse for another. Treat that distinction as part of application design. The server response time guide explains where origin work enters the request; caching changes how often some of that work is needed, not necessarily how fast the uncached operation becomes.

Match Cache-Control to the response

The directives below describe different permissions. Their names are easy to misread, especially no-cache, which does not prohibit storage.

Directive Meaning for a response Suitable decision
max-age=N Freshness lifetime in seconds Choose how long reuse can occur without validation
s-maxage=N Freshness lifetime for shared caches Separate CDN and browser freshness
no-cache Validate before reusing a stored response Keep reusable HTML current
no-store Do not store the response Protect sensitive content that should not be cached
private Shared caches must not store the response Restrict personalized content to a private cache
public Explicitly permits shared storage, subject to other rules Public content intentionally reusable across users
immutable Representation will not change while fresh Content-fingerprinted assets
must-revalidate Do not reuse a stale response without successful validation Prevent unvalidated stale delivery after expiry

A cookie alone does not reliably make a response private. Set the policy explicitly and ensure a CDN does not override it. private does not encrypt the response or prevent the browser from storing it. For a sensitive account response that must not be stored, use Cache-Control: no-store and configure shared-cache bypass as well.

Do not combine directives mechanically until the header looks cautious. A clear policy is easier to audit than a collection of conflicting values. Also remember that adding no-store now does not retrieve copies already stored under an older permissive policy. Plan invalidation and correction of those earlier responses separately. The MDN HTTP caching guide describes the underlying model.

Give changed assets changed URLs

A content hash in a filename connects the URL to a specific build artifact. If app.8f3a1c2d.js changes, publish it under a new hash and update the HTML reference. The old URL must continue to return its old content. Replacing the bytes at an immutable URL breaks the promise that made its long cache lifetime safe.

For public fingerprinted files, this is a common illustrative response policy.

http
Cache-Control: public, max-age=31536000, immutable

The number represents one year in seconds; it is a policy choice, not a required performance threshold. The browser may evict the file sooner because storage is finite. A long lifetime permits reuse; it does not guarantee the resource remains available offline or resident in memory.

Deploy assets before HTML that references them, and retain previous assets for clients still using older HTML or open tabs. A delayed dynamic import may request an earlier chunk after a new deployment. Deleting old chunks immediately can therefore turn a routine release into a failed interaction. Use the build's manifest to identify versions and a retention policy appropriate to the application.

Query-string versioning can also distinguish URLs, but only if every relevant cache includes those parameters in its key. Fingerprinted filenames make the relationship easier to inspect. Do not use a changing timestamp on every request; that prevents useful reuse even when the content is identical.

Revalidate HTML with ETag and Last-Modified

Public HTML at a stable URL often benefits from Cache-Control: no-cache, with validators supplied by the server. An ETag identifies a representation version; it need not be a content hash. Last-Modified supplies a modification time. The application or server must generate values that actually change when the representation changes, including relevant variants.

A browser can send If-None-Match containing its stored ETag. If that validator matches, the server returns 304; otherwise it returns the current representation. If-Modified-Since performs time-based validation with Last-Modified. When both conditional fields apply, the ETag condition takes precedence. Do not hard-code one ETag across pages or releases merely to make responses look cacheable.

For personalized HTML allowed in the user's browser cache but not in a CDN, a starting policy is private, no-cache. For more sensitive responses, choose no-store instead. Authentication, authorization and logout behavior need application testing; headers are not a substitute for access control. Static-file configuration must never accidentally grant a public asset policy to an authenticated download.

Validation can save bytes without saving all origin computation. If the application must generate the entire response to discover its ETag, substantial server work may still occur. Measure both response transfer and backend processing before crediting a caching change with reducing server load.

Configure Apache for a dedicated public asset directory

The following .htaccess belongs only in a directory containing public, content-fingerprinted assets. It requires Apache HTTP Server 2.4, mod_headers and permission to use the relevant overrides. It is not a site-wide policy for uploads, account exports or arbitrary files with familiar extensions.

This rule matches a lowercase hexadecimal fingerprint of at least eight characters before an approved extension.

apache
# public/assets/.htaccess
<FilesMatch "\.[a-f0-9]{8,}\.(css|js|mjs|png|jpg|jpeg|webp|avif|svg|woff2)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

Keep HTML policy in the application or the server location that actually generates HTML. Do not assume a filename rule covers rewritten routes. If another module, framework or proxy already sets Cache-Control, remove the overlapping rule at its source and check the final response for duplicate headers. Apache's header tables and internal redirects can affect how headers are combined.

Validate configuration in staging and confirm that ordinary successful asset responses receive the intended header. Also request a missing file and an authenticated endpoint. An optimization should not turn an error page or private response into a year-long public object. On managed hosting, ask which layer owns caching before modifying controls that may be ignored or overwritten.

Configure Nginx without broadening cache eligibility

For Nginx, use a dedicated path and let only public files with build-generated fingerprints receive the long-lived policy. This fragment belongs inside an existing server block whose document root contains the /assets/ directory. Review location precedence against the site's existing routing before applying it.

The example serves real files and does not forward missing assets to the application.

nginx
location ~ ^/assets/.+\.[a-f0-9]{8,}\.(css|js|mjs|png|jpg|jpeg|webp|avif|svg|woff2)$ {
    try_files $uri =404;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

The absence of always is deliberate: do not indiscriminately attach this policy to every error status. Review add_header inheritance in the installed Nginx version. Adding headers in a nested location can affect inherited security headers, and newer versions expose additional inheritance controls. Preserve the site's required security headers and verify the effective response after any change.

Use nginx -t before reloading, with the privileges required by the deployment. That checks configuration syntax, not the safety of the cache design. Verify the live behavior through the CDN as well as at the origin. A correct origin header is insufficient when an upstream rule replaces it.

Keep variants and other caches visible

Vary tells compliant caches which request headers distinguish representations. If a response varies by content encoding, language or another header, the cache key must account for the real variation. Avoid adding Vary: Cookie as a generic cure for personalized content. It can create many variants and is not a substitute for an explicit private policy or shared-cache bypass.

A CDN's cache is another layer with its own key, lifetime and purge mechanism. Its invalidation usually does not remove browser copies. When a public asset must change immediately, a new URL reaches browser caches more reliably than purging only the edge. The CDN and edge caching guide explains how those decisions interact.

A service worker can intercept requests and maintain Cache Storage separately from the normal HTTP cache. Old service-worker code or an application cache-first strategy may explain a stale response even when HTTP headers look correct. During debugging, inspect the service worker and storage state, then repeat with that layer bypassed. Do not delete users' offline data indiscriminately as a routine deployment technique.

Verify normal visits, updates and private states

Open DevTools' Network panel with Disable cache unchecked. Request the page normally, revisit it, and inspect the asset's response headers and transfer information. A memory or disk cache result differs from a 304 network response. A hard reload can deliberately change caching behavior, so it is not the only test to run.

Keep a small verification matrix covering public HTML, fingerprinted assets, sensitive pages and missing URLs. For each, record the expected storage permission and update behavior. This gives future maintainers a concrete reason for each rule and exposes broad configuration changes that would otherwise look harmless in a single-page test.

Use a real GET when inspecting response headers from the command line, because some servers handle HEAD differently.

bash
curl -sS -D - -o /dev/null https://example.com/assets/app.8f3a1c2d.js

Then deploy a changed asset, verify that HTML references the new filename, and keep an old tab open while testing a delayed feature. Check anonymous, signed-in and signed-out behavior separately. Confirm private pages do not become shared hits and that a missing asset stays an error rather than returning cached HTML.

Record the chosen policy and verification steps in the performance checklist. Use the server performance collection for the next investigation, and choose a representative repeat-navigation journey to measure. The useful result is safe reuse with reliable updates, not a warning removed at the cost of stale or exposed content.

Related guides