Getting caching right involves a balance: cache aggressively enough to reduce repeat downloads, but ensure users always get the latest version after a deployment. This guide covers the standard content-hashing approach that solves both problems simultaneously.
The Core Problem
Long cache times (e.g. one year) mean browsers won't re-download an asset even after you've deployed an update — unless the filename itself changes, giving the browser no reason to think it needs a fresh copy.
The Solution: Content-Hashed Filenames
Modern build tools (Vite, Webpack) automatically generate filenames containing a hash of the file's content:
app.js → app-a1b2c3d4.js
When the content changes, the hash changes, producing a new filename — the old cached version becomes irrelevant since nothing references it anymore, and the new file is fetched fresh because it's a filename the browser has never seen.
Nginx Configuration for Hashed Assets
location ~* \.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
immutable tells the browser this exact file will never change, letting it skip even the revalidation check normally done on cache expiry.
Never Cache the Entry HTML File Aggressively
location = /index.html {
add_header Cache-Control "no-cache";
}
index.html references the current build's specific hashed filenames — it must always be fetched fresh (or at least revalidated), or visitors could load an old HTML file referencing assets that no longer exist.
Cache-Busting for Assets Without Content Hashing
If your build process doesn't produce hashed filenames (e.g. hand-written static sites), append a version query string manually:
<link rel="stylesheet" href="/style.css?v=3">
Less robust than true content hashing (requires remembering to bump the version manually), but effective for simple sites without a build pipeline.
Cache Headers for Images
location ~* \.(jpg|jpeg|png|gif|webp|avif|svg)$ {
expires 30d;
add_header Cache-Control "public";
}
Images often aren't renamed on every deploy the way JS/CSS bundles are, so a shorter but still substantial cache duration (rather than immutable) is a reasonable default unless you're also hash-naming images.
Cache Headers for API Responses (If Applicable)
location /api/ {
add_header Cache-Control "no-store";
}
Dynamic API responses generally shouldn't be cached by the browser at all, unless specifically designed to be cacheable.
Understanding Cache-Control Directives
| Directive | Meaning |
|---|---|
| public | Can be cached by browsers and intermediate caches (CDNs) |
| private | Only the browser should cache it, not shared caches |
| no-cache | Must revalidate with the server before using a cached copy |
| no-store | Never cache at all |
| immutable | The resource will never change; skip revalidation entirely |
| max-age / expires | How long to cache before requiring revalidation |
Verifying Cache Headers
curl -I https://yourdomain.com/assets/app-a1b2c3d4.js
Confirm the Cache-Control header matches what you configured.
Common Errors
Users see a stale version after deployment — verify index.html specifically isn't being cached aggressively, since it's the entry point referencing everything else.
CDN caching interferes with expected behavior — if using a CDN, confirm its own cache settings align with your origin's headers, since CDN-level caching operates independently from browser caching.
Best Practices
- Use content-hashed filenames for JS/CSS wherever your build tooling supports it
- Cache hashed assets aggressively (
immutable, long expiry) - Never aggressively cache the HTML entry point
Continue Reading
- How to Minify and Bundle Frontend Assets for Production
- How to Host a Static Website on a VPS with Nginx
- Nginx Performance Tuning: Worker Processes, Caching & Gzip
Browse more articles in Static Site Hosting & Frontend Deployment.
