Pourquoi votre CDN ne met rien en cache
Tous les fichiers statiques d'agentready.md revenaient avec la même ligne :
cf-cache-status: BYPASS
Polices, feuilles de style, scripts, images. Pas MISS, qui voudrait dire que le edge n'avait pas encore vu le fichier et va garder la copie qu'il vient de chercher. BYPASS veut dire que le edge a lu la réponse, a décidé qu'il n'avait pas le droit de la stocker, et décidera la même chose demain. Chaque visiteur tirait tout depuis l'origine, et aurait continué indéfiniment.
Les quatre réglages qui n'étaient pas en cause
- Development Mode — désactivé. Il coupe entièrement le cache du edge pendant trois heures, et on l'oublie souvent allumé : à vérifier en premier.
- Caching Level — Standard.
- Page Rules — aucune ne mettait Cache Level sur Bypass.
- Browser Cache TTL — une valeur raisonnable.
Tout ce que le tableau de bord avait à dire sur le cache disait garde ça. Le edge ne gardait rien.
La réponse était dans la réponse HTTP
Elle saute aux yeux dès qu'on lit le bloc d'en-têtes en entier au lieu de la seule ligne qu'on était venu chercher :
curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS
La troisième ligne demande un an. La quatrième explique pourquoi personne ne l'accorde.
Une réponse porteuse de Set-Cookie n'est pas stockable dans un cache partagé. Un cookie appartient à un visiteur ; stocker cette réponse et la servir au suivant, c'est lui remettre le cookie de quelqu'un d'autre. Tous les CDN refusent. Cloudflare donne un nom à ce refus, BYPASS ; d'autres se contentent de ne rien stocker, sans le dire.
Le cookie venait de chez nous. Un middleware générait un jeton CSRF à chaque requête et l'attachait, pour qu'un formulaire rendu n'importe où sur le site en trouve un tout prêt. « Chaque requête » incluait celle d'une police woff2 : un fichier qui n'affiche aucun formulaire, ne lit aucun jeton et n'exécute aucun code.
Le correctif
Le CSRF reste tel quel. On cesse simplement de distribuer un jeton à ce qui ne peut pas s'en servir :
const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
'/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);
function servesWithoutCookie(path) {
return COOKIELESS_FILES.has(path)
|| ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
|| path.endsWith('.md');
}
Comparez des chemins, pas des extensions. Certaines des réponses qui réclament le plus de cache sont générées et non lues sur disque — le jumeau Markdown de chaque page, nos badges SVG — et elles non plus n'affichent jamais de formulaire.
Si le cookie n'est pas de vous, regardez du côté des sessions. Beaucoup de frameworks ouvrent une session, et envoient son cookie, à chaque requête qui traverse le middleware, même quand rien n'y a jamais été écrit.
Le diagnostic, sur n'importe quelle stack
Testez un asset, pas la page d'accueil. Une page d'accueil peut être non cacheable pour de bonnes raisons, et elle ne dit rien des trente fichiers qu'elle entraîne avec elle.
for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
echo "== $u"
curl -sI "https://exemple.com$u" \
| grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done
Demandez deux fois la même URL. La seconde doit être un hit : cf-cache-status: HIT, x-cache: Hit from cloudfront, ou un en-tête age: qui monte sur Varnish et nginx. Un second miss signifie que l'objet n'est pas conservé, et la raison est dans les en-têtes que vous venez d'afficher.
Les autres suspects habituels
Cache-Control: private — les navigateurs seulement, jamais un cache partagé. no-store — personne, pas même le navigateur. no-cache ne veut pas dire « ne mets pas en cache » mais « revalide avant chaque réutilisation », ce qui reste un aller-retour par fichier.
Vary: * rend la réponse non cacheable, point. Vary: Cookie est presque aussi mauvais : la clé de cache contient alors une valeur qui change à chaque visiteur, donc plus rien n'est partagé. Vary: User-Agent éclate un fichier en des milliers de variantes. Accept-Encoding est celui qui a sa place ici.
Les chaînes de requête. La plupart des caches utilisent l'URL entière comme clé : chaque variante ?utm_source= ou ?fbclid= d'une page est un objet distinct, tiré de l'origine. Nettoyez ou normalisez les paramètres de campagne au edge.
Et le réglage par défaut du framework, qui n'est pas un bypass mais coûte presque autant : max-age=0 sur chaque fichier statique, c'est une requête de revalidation par asset et par navigation.
Une politique de cache qui tient
La règle porte sur ce que promet l'URL, pas sur ce qu'est le fichier.
Une URL qui contient un hash du contenu (app.css?v=9f2ac41b, app.9f2ac41b.css) est une adresse de contenu : si le fichier change, l'URL change. Celles-là prennent public, max-age=31536000, immutable, et il n'y a jamais rien à purger. Le même fichier sans le jeton prend une heure : le jeton est la promesse, et rien ne se met en cache sur une promesse que personne n'a faite. Un logo ou une image sociale, remplacés sur place, prennent une semaine.
Reste l'exception, celle qu'on rate parce que le fichier a l'air d'être le plus cacheable qu'on possède : tout ce qui est embarqué dans les pages des autres, sur une URL que vous ne pouvez pas versionner. Nous servons badge.js avec une heure pour cette seule raison. Il tourne dans des sites dont nous ne maîtrisons ni le HTML ni l'URL : un an d'immutable, ce serait un bug hors d'atteinte pendant un an.
Si vous servez des fichiers avec @fastify/static
Deux détails qui nous ont coûté un après-midi. Le module envoie Cache-Control: public, max-age=0 par défaut. Et si vous posez l'en-tête vous-même via setHeaders, passez aussi cacheControl: false, sinon la bibliothèque fabrique le sien à partir de son maxAge par défaut et écrase le vôtre — sans un mot, en laissant quelque chose qui a l'air délibéré :
app.register(fastifyStatic, {
root: join(__dirname, 'public'),
cacheControl: false, // sinon setHeaders est écrasé
setHeaders: setStaticCacheHeaders,
});
Avant de toucher au moindre réglage de cache, lisez une réponse en entier. L'en-tête qui casse le cache est rarement celui qui porte « cache » dans son nom.
Analyser votre site · Checklist de préparation à l'IA · Les bots sont-ils autorisés ?