Cache busting : pourquoi votre ?v= ne sert à rien
Notre navigation s'est affichée sans aucun style en production pendant une bonne partie d'un après-midi. Le HTML était correct, le fichier CSS sur le serveur était correct, et le déploiement s'était terminé sans erreur. Le diagnostic a été faux deux fois avant que quelqu'un regarde au bon endroit.
app.css partait sans le moindre ?v=. Cloudflare le gardait en cache quatre heures, les navigateurs bien plus longtemps. Cette version renommait une série de classes CSS : le nouveau HTML arrivait donc dans un navigateur qui détenait encore la feuille de style du build précédent, et plus une seule classe du balisage ne correspondait à quoi que ce soit.
Ce qui trahissait un problème de cache et non de déploiement est précisément ce qui le rendait invisible : un rechargement forcé réglait tout, et quiconque ouvrait le site pour la première fois le voyait normalement. Un cache froid est indiscernable d'un déploiement réussi, donc personne testant sur un profil propre n'a jamais reproduit le bug.
app.js, lui, avait bien un ?v=, et c'était pire que rien. Il interpolait la version du package.json :
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
Ce numéro ne bouge pas d'une release à l'autre, et nous publions beaucoup plus souvent que nous ne l'incrémentons. Le JS portait donc un jeton qui ressemblait à du cache busting pour quiconque lisait le gabarit, et n'invalidait rien. Le même bug que le CSS, à une release de se déclencher, avec un leurre devant.
Hachez le fichier
Un jeton de cache busting n'a qu'un seul travail : changer quand le fichier change, et à aucun autre moment. La seule valeur qui fasse cela dérive des octets du fichier lui-même.
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
function assetVersion(relativePath) {
const buf = readFileSync(join(__dirname, 'public', relativePath));
return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}
const cssVersion = assetVersion('css/app.css');
Lu une seule fois au démarrage : ce sont des sorties de build, elles ne peuvent pas changer sous un processus en cours. Huit caractères hexadécimaux suffisent largement, et SHA-1 convient ici, parce que vous évitez une collision accidentelle entre deux versions de votre propre fichier, pas un attaquant qui en choisirait le contenu.
Le gabarit cesse alors de mentir :
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
Les trois jetons employés à la place :
Un numéro de version (?v=1.4.2) change quand un humain pense à le changer, ce qui n'est pas la même chose que quand le fichier change. C'était notre app.js.
Un horodatage ou un identifiant de build change bien à chaque déploiement, donc il ne sert jamais de contenu périmé, mais il change aussi quand rien n'a changé. Chaque déploiement jette le CSS en cache de tous vos visiteurs et la copie de chaque nœud du CDN, qu'un seul octet ait bougé ou non : une release qui n'a touché que le backend coûte un téléchargement à chaque utilisateur.
Une valeur aléatoire (?v=<%= Math.random() %>, et certains l'écrivent vraiment) donne une URL neuve à chaque affichage de page. Cela n'invalide pas le cache, cela l'éteint définitivement pour tout le monde.
Seul le hash du contenu gagne des deux côtés : jamais périmé, et jamais retéléchargé quand rien n'a changé.
Query string ou nom de fichier
app.a1b2c3d4.css fait la même promesse avec le hash dans le nom de fichier plutôt que dans la query string. Il faut une étape de build qui réécrive toutes les références, d'où sa présence habituelle dans les projets à bundler et son absence dans ceux rendus côté serveur.
L'argument historique en faveur du nom de fichier, c'est que certains caches ignorent la query string dans le calcul de la clé, ce qui réduit le ?v= à de la décoration. C'est encore un réglage que l'on peut activer par mégarde, le niveau de cache de Cloudflare proposant une option « ignore query string », et s'il est actif tous les ?v= de votre site cessent silencieusement de fonctionner. Vérifiez avant de compter dessus.
immutable est une promesse sur l'URL, pas sur le fichier
Cache-Control: public, max-age=31536000, immutable dit au navigateur de ne pas revalider, même quand l'utilisateur recharge. Ce n'est vrai que si l'URL est une adresse de contenu, et formulée ainsi la règle s'écrit toute seule :
if (/\.(css|js)$/.test(name)) {
const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
res.setHeader('Cache-Control', versioned
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600');
}
Le même fichier demandé sans le jeton reçoit une heure. Le jeton est la promesse, et rien ne doit être mis en cache un an sur une promesse que personne n'a faite.
Vérifiez que votre middleware de statiques ne l'écrase pas. @fastify/static envoie max-age=0 par défaut ; posez des en-têtes sans désactiver son propre cacheControl et il reconstruit l'en-tête à partir de cette valeur et remplace le vôtre en silence. L'en-tête est toujours là, il a toujours l'air délibéré, et il est faux.
Les polices, nous les marquons immutable par leur nom. Un sous-ensemble se remplace en renommant, pas en éditant : le nom est déjà l'adresse du contenu. Idem pour les bibliothèques vendorisées, alpine-3.14.8.min.js porte sa version dans son nom et personne ne l'édite sur place.
Les images ont une semaine, pas un an. Elles ne sont pas versionnées, et un logo ou un favicon, lui, est bel et bien remplacé en place : assez long pour compter, assez court pour corriger une bêtise.
Le fichier qui doit rester à durée courte
Nous servons un script de badge qui s'exécute dans les pages d'autres gens. Nous ne contrôlons ni leur HTML ni l'URL qu'ils intègrent. Un an d'immutable là-dessus, c'est un bug hors de portée pendant un an : aucun moyen d'invalider, aucun moyen de demander à chaque site portant la balise de la changer. Il reçoit une heure, et c'est le bon choix même si le fichier ne change presque jamais.
Tout ce qui vit sur une URL que vous ne pouvez pas versionner fonctionne ainsi. Sa durée de cache est fixée par la vitesse à laquelle vous devez pouvoir le corriger, pas par sa fréquence de changement.
Vérifiez
Le jeton présent dans le HTML correspond-il au fichier sur le disque ?
curl -s https://votresite.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
La même chaîne, sinon votre HTML pointe vers quelque chose qu'il ne décrit pas. Si le jeton ne bouge pas entre deux déploiements qui ont tous deux modifié le CSS, vous avez notre app.js.
Ensuite, demandez à la ressource ce qu'elle répond :
curl -sI 'https://votresite.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
last-modified est ce qui attrape un nœud périmé. S'il est antérieur à votre dernier déploiement de ce fichier, quelque chose entre votre serveur et le navigateur détient encore l'ancienne copie, et c'est le jeton dans votre HTML qui décide si cela vous nuit.
Analyser votre site · Checklist de préparation à l'IA · Ce que nous mesurons