Caching and the CDN
Hashed assets get a year and immutable; index.html gets no-cache. Reverse the two and every user stays on an old build.
Cache-Control: public, max-age=31536000, immutableVite gives every imported asset a filename containing a hash of its contents: index-B7g1dQrT.js. Change one byte of source and the name changes with it. That is what makes the file safe to cache forever, because a new build asks for a different URL rather than a fresher copy of the same one.
The two rules
Everything under /assets/ gets a long lifetime: Cache-Control: public, max-age=31536000, immutable. A year is the conventional maximum, and immutable tells the browser not to revalidate even when the user presses reload. index.html gets Cache-Control: no-cache, because its name never changes and it is the file that names the current chunks. Vite's deployment notes recommend no-cache on HTML for exactly this reason.
Getting it backwards
Cache index.html for a year and users keep running whichever build they first loaded, for as long as their cache holds it. Deploying again does not reach them. Cache the hashed assets for nothing and every navigation re-downloads the application, which is slow but self-correcting. The first failure is the one that produces support tickets, because it looks as though the deploy never happened.
Two details
Files you put in public/ are copied to the output root unchanged and are never hashed. They must not get the immutable header, or you can never replace them. And no-cache does not mean do not store: the browser keeps the file and revalidates it, usually getting a cheap 304 back. no-store is the directive that forbids storing at all.
Example
<script setup lang="ts">
// Imported: Vite rewrites this to a hashed URL such as
// /assets/logo-DfTx19aQ.svg, which can be cached for a year.
import logoUrl from '../assets/logo.svg'
// From public/: the name is whatever you called it, forever. BASE_URL keeps
// the path correct when the app is deployed under a subdirectory.
const touchIconUrl = `${import.meta.env.BASE_URL}apple-touch-icon.png`
</script>
<template>
<header class="brand">
<img :src="logoUrl" alt="Soundscode" width="120" height="32" />
<img :src="touchIconUrl" alt="" width="24" height="24" />
</header>
</template>
<style scoped>
.brand {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
}
</style>When to use it
- Putting a CDN in front of a built SPA without having to purge it on every deploy.
- Diagnosing why some users still see last week's build after a successful deploy.
- Deciding whether a file belongs in public/ or should be imported so it gets hashed.
More examples
nginx cache headers
Three locations, three policies, in the order nginx matches them: exact-match `=` blocks win over the prefix blocks, so `/index.html` and `/config.json` are not swallowed by `location /`. Reach for this layout whenever you serve the build yourself. The `always` flag is what makes the header appear on a 304 or a 404 as well as a 200, which matters because a revalidated index.html comes back as 304.
# docker/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Content-hashed output. A new build is a new URL.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# Copied from public/ as-is, so the name is stable and the body is not.
location = /config.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}public/_headers for a managed host
The same two rules on a host where you do not own a web server. The file lives in `public/`, so Vite copies it to the root of `dist` where the platform looks for it. Rules are matched most-specific-first, which is why the `/*` block at the end adds headers without undoing the cache policies above it. Use this when the CDN is the only thing in front of your files.
# public/_headers -- Netlify and Cloudflare Pages
/assets/*
Cache-Control: public, max-age=31536000, immutable
/index.html
Cache-Control: no-cache
/config.json
Cache-Control: no-cache
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-originvite.config.ts
The three build options that decide what your cache rules can target, each shown with its documented default so you can see what you are changing. `assetsInlineLimit` is the subtle one: a 2 KB SVG never becomes a separate request at all, so it is cached exactly as long as the JavaScript or CSS that swallowed it. Raise it and more small files follow the bundle's lifetime; lower it and they get their own hashed URLs.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
// Default '/'. Change it, and the router's createWebHistory, together.
base: '/',
build: {
// Default 'dist'.
outDir: 'dist',
// Default 'assets'. This is the directory your immutable header targets,
// so if you rename it, rename it in the server config too.
assetsDir: 'assets',
// Default 4096 bytes. Anything smaller is inlined as a data URL and so
// inherits the cache policy of the file that imported it.
assetsInlineLimit: 4096,
},
})
Discussion