Der Browser hält die alte Fassung fest
Eine neue Fassung liegt auf dem Server, und der Browser liefert trotzdem die alte. Der Grund steht in der HTTP-Spezifikation. Die Lösung sind ein paar Zeilen JavaScript und eine Datei mit 27 Byte.
Unter tools.bitblade.io liegen ein paar kleine Werkzeuge: ein Datumsrechner, eine Arbeitszeit-Hilfe, ein Tätigkeiten-Tracker. Jedes davon ist genau eine HTML-Datei. Kein Backend, keine Datenbank, kein Login. Was jemand erfasst, liegt im localStorage seines Browsers und verlässt das Gerät nicht.
Das hat einen Preis, der lange nicht auffällt. Es gibt keinen Server, der einem offenen Browser sagen könnte, dass es etwas Neues gibt. Wir laden eine neue Datei hoch — und wer die Seite offen hat oder sie am nächsten Tag wieder aufruft, arbeitet unter Umständen wochenlang weiter mit der alten.
Warum der Browser die alte Datei behält
Das ist kein Fehler, das ist die Spezifikation. So antwortet der Server auf die Werkzeugseite:
HTTP/2 200
server: nginx
content-type: text/html; charset=UTF-8
content-length: 62793
last-modified: Sun, 20 Sep 2026 11:24:10 GMT
etag: "f549-65be85f56b530"
Interessant ist, was fehlt: kein Cache-Control, kein Expires. Ohne diese Vorgaben darf der Browser selbst schätzen, wie lange die Antwort frisch bleibt — heuristisches Caching, beschrieben in RFC 9111, Abschnitt 4.2.2. Die übliche Heuristik nimmt ein Zehntel der Zeit, die seit der letzten Änderung vergangen ist.
Die Datei, die wir ersetzt haben, lag seit dem 30. Mai unverändert auf dem Server. Beim Aufruf im September war sie damit rund 113 Tage alt, und ein Zehntel davon sind gut elf Tage. Elf Tage, in denen der Browser die Seite ausliefert, ohne den Server überhaupt zu fragen.
Der Effekt dreht sich damit genau falsch herum: Je länger eine Datei stabil war, desto zäher hält der Browser an der alten Fassung fest.
Warum „einmal Strg+F5" keine Antwort ist
Man kann den Leuten sagen, sie sollen die Seite hart neu laden. Das setzt voraus, dass sie wissen, dass es etwas Neues gibt. Genau das wissen sie nicht — sonst bräuchte es den Hinweis nicht.
Der zweite Vorschlag lautet dann meist „Browserdaten löschen". Der Schalter dafür heißt in den gängigen Browsern nicht „Cache leeren", sondern „Cookies und andere Websitedaten löschen". Websitedaten, das ist auch der localStorage. Bei einem Werkzeug, dessen gesamter Inhalt im localStorage liegt, ist dieser Klick kein Wartungsschritt, sondern Datenverlust: Wer drei Monate Arbeitszeiten erfasst hat, hat sie danach nicht mehr.
Damit war die Anforderung klar. Die Seite muss selbst merken, dass sie veraltet ist, und sich selbst erneuern, ohne irgendetwas anzufassen, was der Nutzer eingegeben hat.
Der Abgleich: zwei Werte, die zusammengehören
Die Seite trägt ihre Version im Kopf:
<meta name="app-version" content="2026-09-20.2" />
Daneben im selben Ordner liegt eine Datei version.json:
{"version":"2026-09-20.2"}
27 Byte. Die Seite liest ihren eigenen Wert aus dem Meta-Tag, holt den anderen vom Server und vergleicht:
fetch('version.json?t=' + Date.now(), { cache: 'no-store' })
.then(r => r.json())
.then(d => {
if (d.version && d.version !== APP_VERSION) announce(d.version);
});
Zwei Vorkehrungen gegen genau das Problem, das hier gelöst werden soll: cache: 'no-store' umgeht den Browsercache, der Zeitstempel im Query-String umgeht alles, was auf dem Weg dorthin sonst noch zwischenspeichert.
Geprüft wird acht Sekunden nach dem Laden, danach alle fünf Minuten, zusätzlich bei visibilitychange, focus und online. Der Tab, der seit gestern offen ist, merkt damit beim Zurückwechseln, dass sich etwas geändert hat, statt erst in fünf Minuten.
Warum nicht einfach ETag oder Last-Modified
Der naheliegende Einwand: Die Header sind doch da. Ein HEAD-Request auf die eigene Adresse liefert ETag und Last-Modified, und die zweite Datei wäre überflüssig.
Der Haken ist der Bezugspunkt. Die laufende Seite müsste wissen, welchen ETag ihr eigener Inhalt hatte — und genau das weiß sie nicht. Der Fall, um den es geht, ist ja der, in dem sie selbst aus dem Cache kam und veraltet ist.
Ein Vergleich gegen einen beim Start abgeholten Serverwert erkennt diesen Fall nie: Beim ersten Abruf ist der Server immer „aktuell", danach gibt es nur noch Änderungen ab jetzt. Ein Vergleich gegen eine einkompilierte Bauzeit scheitert anders herum — die Datei auf dem Server ist immer später datiert als der Zeitstempel, den wir vor dem Hochladen hineinschreiben. Daraus wird eine Endlosschleife.
Eine Versionsnummer, die in beiden Dateien wörtlich gleich steht, kennt diese Zweifel nicht. Sie kostet eine zusätzliche Datei und die Disziplin, beide gemeinsam hochzuladen.
Neu laden, ohne etwas wegzuwerfen
const u = new URL(location.href);
u.searchParams.set('v', neueVersion);
location.replace(u.toString());
Der Query-Parameter ist der eigentliche Punkt. Für den Cache ist /taetigkeiten/?v=2026-09-20.3 eine andere Ressource als /taetigkeiten/, die Datei wird also tatsächlich vom Server geholt statt aus dem Zwischenspeicher. location.reload(true) wäre der Reflex — das Argument ist in allen aktuellen Browsern ohne Wirkung.
Was dabei ausdrücklich nicht passiert: localStorage, sessionStorage und Cookies hängen am Origin, nicht an der einzelnen URL. Ein zusätzlicher Query-Parameter ändert den Origin nicht. Die erfassten Daten bleiben liegen. Wir haben das gegengeprüft, mit gesetztem localStorage-Eintrag und einem Testcookie vor und nach dem Neuladen — beide unverändert vorhanden.
location.replace statt einer Zuweisung an location.href, damit die veraltete Fassung nicht als Station im Verlauf zurückbleibt und ein „Zurück" nicht genau dorthin führt. Den ?v=-Parameter räumt die neue Fassung direkt nach dem Start wieder aus der Adresszeile:
history.replaceState(null, '', location.pathname);
Zwei Sicherungen
Nicht neu laden, während jemand tippt. Steht der Fokus in einem Eingabefeld, wenn die neue Version eintrifft, wartet der Reload und fragt kurz darauf erneut. Ein Formular, das sich beim Ausfüllen selbst wegzieht, wäre eine schlechtere Erfahrung als eine veraltete Seite.
function isTyping() {
const el = document.activeElement;
if (!el) return false;
const t = (el.tagName || '').toLowerCase();
if (t === 'textarea' || t === 'select') return true;
if (t !== 'input') return false;
return ['text','search','number','date','time','color','email','url']
.indexOf((el.type || 'text').toLowerCase()) !== -1;
}
Ein Schleifenschutz. Meldet version.json eine Version, die die HTML-Datei nie bekommt — weil ein Upload halb durchging oder eine Datei vergessen wurde —, lädt die Seite sonst endlos neu. Ein Zähler in sessionStorage begrenzt das auf zwei Versuche. Danach bleibt nur der Hinweisbalken stehen, mit der Bitte, einmal von Hand neu zu laden.
sessionStorage und nicht localStorage: Der Zähler soll mit dem Tab verschwinden und nicht dauerhaft neben den Daten des Nutzers liegen.
Was es kostet
Pro offenem Tab eine Anfrage über 27 Byte alle fünf Minuten, dazu je eine beim Zurückwechseln auf den Tab. Kein Build-Schritt, kein Service Worker, keine Server-Komponente, keine zusätzliche Abhängigkeit.
Ein Service Worker könnte dasselbe und mehr — Offline-Betrieb, genaue Kontrolle darüber, was wann im Cache liegt. Er bringt dafür einen eigenen Lebenszyklus mit: Installation, Wartezustand, skipWaiting, clients.claim, und die bekannte Eigenart, dass ein fehlerhaft ausgelieferter Service Worker selbst hartnäckig bestehen bleibt. Für eine einzelne HTML-Datei ohne Offline-Anspruch steht das in keinem Verhältnis.
Die Bedingung, die bleibt
Der Ansatz hat einen Preis, und der heißt Disziplin: Bei jeder Änderung müssen beide Werte gemeinsam hoch, der im Meta-Tag und der in version.json.
Wird version.json vergessen, passiert nichts — niemand bekommt die neue Fassung, und niemand merkt es. Wird die HTML-Datei vergessen, greift nach zwei Reloads der Schleifenschutz. Der zweite Fehler zeigt sich sofort, der erste bleibt still. Deshalb hängt der Versionsstring bei uns am Deploy-Datum und wird im selben Arbeitsschritt an beiden Stellen gesetzt, nicht im Nachgang.
An der Wurzel: die Header
Der Vollständigkeit halber gehört die andere Hälfte der Lösung dazu. Ein Cache-Control: no-cache für HTML-Dokumente zwingt den Browser, vor jeder Auslieferung beim Server nachzufragen; Stylesheets, Skripte und Bilder bekommen im Gegenzug lange Haltbarkeit und einen versionierten Dateinamen. Wer an die Serverkonfiguration kommt, sollte das einrichten — es beendet das heuristische Raten.
Einen Fall löst es allerdings nicht: den Tab, der seit gestern offen ist. Der fragt von sich aus niemanden mehr. Beide Wege ergänzen sich also — die Header für den nächsten Aufruf, der Versionscheck für die laufende Sitzung.
Wo das Muster sonst passt
Überall dort, wo statische Dateien ohne Deployment-Pipeline ausgeliefert werden und trotzdem aktuell sein sollen: interne Werkzeuge, Landingpages, Dokumentations- und Statusseiten, Preislisten. Zwei Dateien, ein Abgleich, ein Neuladen, das nichts anfasst, was dem Nutzer gehört.
Die Code-Ausschnitte stammen aus dem Tätigkeiten-Tracker auf tools.bitblade.io und sind für die Lesbarkeit gekürzt.