Asset Versioning & Browser Cache Strategy
debt(d3/e3/b3/t5)
Closest to 'default linter catches the common case' (d3) — Lighthouse audits flag missing Cache-Control headers and inefficient cache policies on static assets directly, and curl can verify headers easily.
Closest to 'simple parameterised fix' (e3) — quick_fix is configuring build tool (webpack/vite) to emit hashed filenames and adjusting server cache headers; touches build config and server config but is a well-known pattern.
Closest to 'localised tax' (b3) — applies only to web context's static asset pipeline; once configured the build tool handles it, but it does shape the deployment/asset-serving layer.
Closest to 'notable trap' (t5) — the misconception (long max-age = stale users) is exactly the documented gotcha most devs learn; query-string busting and missing immutable directive are subtle pitfalls that contradict naive intuition.
Also Known As
TL;DR
Explanation
The optimal static asset strategy: append a content hash to filenames (app.a3f8d2.js) and serve with Cache-Control: public, max-age=31536000, immutable. Browsers cache indefinitely; on next deploy the hash changes so the new filename is fetched fresh — no stale asset problem. The HTML document itself gets Cache-Control: no-cache, must-revalidate so it always reflects the latest asset filenames. Build tools (Vite, Webpack, Laravel Mix) handle hash generation and manifest files that PHP uses to resolve hashed filenames. For PHP-served dynamic content: use ETag or Last-Modified headers and respond with 304 Not Modified to save bandwidth. Avoid query-string versioning (?v=123) — some CDNs and proxies strip or ignore query strings when caching.
Diagram
flowchart LR
subgraph No Content Hash
BROWSER[Browser] -->|GET app.css| SERVER[Server]
SERVER -->|200 app.css v1| BROWSER
DEPLOY[New deploy v2] --> SERVER
BROWSER -->|cached - serves v1!| OLD[Stale CSS]
end
subgraph Content Hash in Filename
B2[Browser] -->|GET app.a3f2c.css| S2[Server]
S2 -->|200 Cache-Control: max-age=31536000| B2
DEPLOY2[New deploy] --> NEW[app.b7d9e.css - new hash]
B2 -->|new filename - fetches fresh| FRESH[Always fresh]
end
style OLD fill:#f85149,color:#fff
style FRESH fill:#238636,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Using a query string for cache-busting (?v=2) — some proxies ignore query strings and serve stale content.
- Short Cache-Control max-age on static assets — defeats the performance benefit.
- Not using immutable directive for fingerprinted assets — browsers may still revalidate without it.
- Cache-Control: no-cache on static assets in production — every request hits the server.
Code Examples
// Version query string — proxy-unfriendly cache busting:
<script src="/app.js?v=1.2"></script>
// Content-hash filename — correct approach:
<script src="/app.abc123f.js"></script>
<!-- Server: Cache-Control: public, max-age=31536000, immutable -->
// Content-hash filenames + immutable cache headers
// Build tool (Vite) output: app.a3f8d2b1.js
// PHP: read Vite manifest to resolve hashed filenames
$manifest = json_decode(file_get_contents(public_path('build/manifest.json')), true);
$assetUrl = '/build/' . $manifest['resources/js/app.js']['file'];
// → /build/assets/app-a3f8d2b1.js
// Serve with immutable header (nginx)
location ~* \.(js|css|woff2|png|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary Accept-Encoding;
}
// HTML document: no-cache so browsers always check for new asset filenames
add_header Cache-Control "no-cache, must-revalidate";