Cache-Busting: Warum Ihr ?v= wahrscheinlich nichts bewirkt
Unsere Navigation wurde in Produktion einen halben Nachmittag lang völlig ohne Styles gerendert. Das HTML stimmte, die CSS-Datei auf dem Server stimmte, und das Deployment war fehlerfrei durchgelaufen. Zweimal falsch diagnostiziert, bevor jemand an der richtigen Stelle nachsah.
app.css ging ohne jedes ?v= raus. Cloudflare hielt sie vier Stunden im Cache, Browser deutlich länger. Das Release benannte eine Reihe von CSS-Klassen um, also traf neues HTML auf einen Browser, der noch das Stylesheet des vorherigen Builds hielt: keine einzige Klasse im Markup passte mehr auf irgendetwas.
Was einen Cache statt eines kaputten Deployments verriet, ist genau das, was es so schwer sichtbar machte: Ein erzwungenes Neuladen behob es, und wer die Seite zum ersten Mal öffnete, sah sie korrekt. Ein kalter Cache ist von einem funktionierenden Deployment nicht zu unterscheiden, deshalb hat niemand mit sauberem Profil den Fehler je reproduziert.
app.js hatte ein ?v=, und das war schlimmer als keines. Es interpolierte die Version aus der package.json:
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
Diese Zahl bewegt sich zwischen Releases nicht, und wir veröffentlichen weit häufiger, als wir sie erhöhen. Das JS trug ein Token, das für jeden, der das Template las, nach Cache-Busting aussah und nichts invalidierte. Derselbe Fehler wie beim CSS, ein Release davon entfernt zuzuschlagen, mit einer Attrappe davor.
Hashen Sie die Datei
Ein Cache-Busting-Token hat genau eine Aufgabe: sich zu ändern, wenn sich die Datei ändert, und sonst nie. Der einzige Wert, der das leistet, stammt aus den Bytes der Datei selbst.
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');
Einmal beim Start gelesen: Das sind Build-Artefakte, sie können sich unter einem laufenden Prozess nicht ändern. Acht Hex-Zeichen reichen völlig, und SHA-1 genügt hier, weil Sie eine zufällige Kollision zwischen zwei Versionen Ihrer eigenen Datei vermeiden und nicht einen Angreifer, der den Inhalt wählen darf.
Danach hört das Template auf zu lügen:
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
Die drei Token, zu denen stattdessen gegriffen wird:
Eine Versionsnummer (?v=1.4.2) ändert sich, wenn ein Mensch daran denkt, sie zu ändern, und das ist nicht dasselbe wie: wenn sich die Datei ändert. Das war unser app.js.
Ein Zeitstempel oder eine Build-ID ändert sich tatsächlich bei jedem Deployment, liefert also nie Veraltetes, ändert sich aber auch, wenn sich nichts geändert hat. Jedes Deployment wirft das gecachte CSS jedes Besuchers und die Kopie jedes CDN-Knotens weg, ob sich ein Byte bewegt hat oder nicht, und ein Release, das nur das Backend berührt hat, kostet jeden Nutzer trotzdem einen Download.
Ein Zufallswert (?v=<%= Math.random() %>, und Leute schreiben das wirklich) ergibt bei jedem Seitenaufruf eine neue URL. Das invalidiert den Cache nicht, es schaltet ihn dauerhaft für alle ab.
Nur der Content-Hash trifft beide Hälften: nie veraltet und nie neu geladen, wenn sich nichts geändert hat.
Query-String oder Dateiname
app.a1b2c3d4.css macht dieselbe Zusage mit dem Hash im Dateinamen statt im Query-String. Dafür braucht es einen Build-Schritt, der jede Referenz umschreibt, weshalb Projekte mit Bundler das üblicherweise haben und servergerenderte üblicherweise nicht.
Das alte Argument für den Dateinamen: Manche Caches ignorieren den Query-String beim Berechnen des Cache-Keys, womit ?v= zur Dekoration wird. Das ist bis heute eine Einstellung, die man versehentlich aktiviert, denn die Cache-Stufe von Cloudflare kennt eine Option „ignore query string“, und ist sie an, funktioniert jedes ?v= auf Ihrer Seite stillschweigend nicht mehr. Prüfen Sie das, bevor Sie sich darauf verlassen.
immutable ist ein Versprechen über die URL, nicht über die Datei
Cache-Control: public, max-age=31536000, immutable sagt dem Browser, nicht zu revalidieren, auch nicht beim Neuladen. Das stimmt nur, wenn die URL eine Inhaltsadresse ist, und so formuliert schreibt sich die Regel von selbst:
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');
}
Dieselbe Datei ohne Token bekommt eine Stunde. Das Token ist das Versprechen, und nichts sollte ein Jahr lang auf ein Versprechen hin gecacht werden, das niemand gegeben hat.
Prüfen Sie, ob Ihre Static-Middleware das nicht überschreibt. @fastify/static sendet standardmäßig max-age=0; setzen Sie Header, ohne dessen eigenes cacheControl abzuschalten, baut es den Header aus diesem Standard neu auf und überschreibt Ihren lautlos. Der Header ist noch da, sieht weiterhin absichtsvoll aus und ist falsch.
Schriften markieren wir per Namen als immutable. Ein Subset-Build wird durch Umbenennen ersetzt, nicht durch Bearbeiten, der Dateiname ist also bereits die Inhaltsadresse. Gleiches gilt für mitgelieferte Bibliotheken: alpine-3.14.8.min.js trägt seine Version im Namen, und niemand bearbeitet sie an Ort und Stelle.
Bilder bekommen eine Woche, kein Jahr. Sie sind unversioniert, und ein Logo oder ein Favicon wird tatsächlich an derselben Adresse ersetzt: lang genug, dass es etwas bringt, kurz genug, um einen Fehler zu korrigieren.
Die Datei, die kurzlebig bleiben muss
Wir liefern ein Badge-Skript aus, das in fremden Seiten läuft. Wir kontrollieren weder deren HTML noch die URL, die sie einbinden. Ein Jahr immutable hieße dort: ein Bug, an den wir ein Jahr lang nicht herankommen. Kein Weg zu invalidieren, kein Weg, jede Seite mit dem Tag um eine Änderung zu bitten. Es bekommt eine Stunde, und das ist richtig, obwohl sich die Datei fast nie ändert.
Alles auf einer URL, die Sie nicht versionieren können, verhält sich so. Seine Cache-Lebensdauer bestimmt, wie schnell Sie es korrigieren können müssen, nicht wie oft es sich ändert.
Nachprüfen
Passt das Token im HTML zur Datei auf der Platte?
curl -s https://ihreseite.de/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
Dieselbe Zeichenkette, sonst zeigt Ihr HTML auf etwas, das es nicht beschreibt. Bleibt das Token über zwei Deployments hinweg stehen, die beide das CSS geändert haben, haben Sie unser app.js.
Dann fragen Sie das Asset, was es antwortet:
curl -sI 'https://ihreseite.de/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
last-modified ist das, was einen veralteten Knoten auffliegen lässt. Liegt es vor Ihrem letzten Deployment dieser Datei, hält irgendetwas zwischen Server und Browser noch die alte Kopie, und ob Sie das trifft, entscheidet das Token in Ihrem HTML.