TL;DR: Der Server war schnell (TTFB unter 0,5 s, CDN-Hit). Der Browser lud trotzdem 42,8 MB pro Detailseite, weil die Seite für die Auflösung eines einzigen Slugs alle 83 Datendateien nachlud — ungecacht, weil der LocalStorage-Cache 42 MB nicht halten kann. Parallel fand sich: Die
headers-Regeln der Deployment-Konfiguration wurden nie ausgeliefert, weil ein Low-Level-routes-Array sie verdrängt. Nach den Maßnahmen: Homepage −73 % Transfer, Detailseiten von 42,8 MB auf wenige Megabyte, Security-Header live verifiziert inkl. enforced CSP.
Ausgangslage
Ich habe nicht geraten, sondern gemessen. Ein Aufruf der Startseite mit deaktiviertem Cache, aufgezeichnete Resource-Timings, fünf Seitentypen:
| Seite | Transfer | Requests |
|---|---|---|
| Detailseite (Arzt) | 42.842 KB | 103 |
| Detailseite (Restaurant) | 42.842 KB | 103 |
| Startseite | 5.947 KB | 59 |
| Analysen-Übersicht | 9.163 KB | 27 |
| Buchungsseite | 1.011 KB | 16 |
Der erste Blick galt dem Server — und der war unauffällig: time_starttransfer zwischen 0,11 und 0,50 Sekunden, x-vercel-cache: HIT, Bundles als Brotli mit immutable. Das Problem lag vollständig im Client.
Befund 1 — Jede Unterseite lud die gesamte Datenbank
Die Detailseite braucht genau einen Datensatz: den Betrieb, dessen Slug in der URL steht. Der Code lud dafür alle Kategoriedateien — 83 CSV-Dateien, roh 122,6 MB, komprimiert 37,2 MB. Zwei Verstärker kamen dazu:
- Der Cache konnte nicht greifen. Die CSVs werden zusätzlich in
localStoragegehalten (TTL eine Stunde). Quota: 5–10 MB. Ein 42-MB-Datensatz passt nicht hinein, jeder Schreibversuch läuft inQuotaExceeded— und der nächste Seitenaufruf lädt alles neu. - Fehlende HTTP-Cache-Regel.
/data/*wurde mitmax-age=0, must-revalidateausgeliefert. Der Browser revalidierte also bei jedem Navigationswechsel.
Der Doppelbefund erklärt, warum sich die Seite „extrem langsam" anfühlt, obwohl jeder einzelne Request schnell beantwortet wird: Die Menge war das Problem, nicht die Latenz.
Nebenbei: Ein Blick in die Dateigrößen zeigt, wo die Bytes stecken — 87 bis 93 % jeder großen CSV sind Rezensionstexte, von denen die Detailseite genau einen anzeigt.
Befund 2 — Die Security-Header existierten nur in der Datei
Beim Prüfen der Header fiel auf, dass HSTS mit dem Vercel-Default antwortete statt mit dem konfigurierten Wert. Gegenprobe mit drei Regeln aus der Konfiguration:
$ curl -sI https://example.org/booking | grep -i x-robots
# keine Ausgabe — Regel existiert in der Konfiguration, wird aber nicht ausgeliefert
$ dig +short TXT _dmarc.example.org
"v=DMARC1; p=quarantine; rua=mailto:...; pct=100"
Die Deployment-Konfiguration enthielt zwei Routing-Ebenen: ein Low-Level-routes-Array (mit handle: filesystem und einem Rewrite auf eine Serverless-Funktion) und die höherwertigen redirects/headers-Properties. Ergebnis im Betrieb: routes greifen, redirects und headers werden ignoriert.
Drei Belege, alle reproduzierbar:
- Drei Weiterleitungen aus der Konfiguration antworten mit 404 statt 301.
- Die
X-Robots-Tag-Regel für die Buchungsseite fehlt. - HSTS trägt den Plattform-Default, nicht den konfigurierten
preload-Wert.
Die Lehre ist unangenehm einfach: Konfiguration ist keine Auslieferung. Was nicht per curl -I nachgewiesen ist, existiert nicht.
Befund 3 — Eine Redirect-Endlosschleife, live
Zwei Weiterleitungsziele waren Fehlkonfigurationen:
- Ein Stub zeigte auf einen Pfad, der per 301 zurück auf den Stub zeigt — für echte Besucher eine Endlosschleife.
- Ein zweiter Stub zeigte auf ein 404-Ziel.
Beide waren in der alten, wirkungslosen redirects-Sektion angelegt — und die Weiterleitung, die tatsächlich griff, kam aus dem routes-Array. Der Widerspruch blieb jahrelang unbemerkt, weil niemand die Ziel-URL aufgerufen hatte.
Das Vorgehen: erst simulieren, dann bauen
Der naheliegende Fix — „lade nur die Dateien der passenden Kategorie" — wäre ein Datenverlust gewesen. Die Slug-Auflösung hängt an einer globalen Reihenfolge über alle Dateien (Kollisionen werden mit Stadt-Suffixen aufgelöst). Eine Simulation des naiven Ansatzes gegen alle 73.676 gebauten Routen ergab:
- 284 Routen hätten auf einen fremden Betrieb gezeigt,
- 38 Routen wären leer geblieben,
- dazu 186 Routen, die gar nicht im Index stehen (von einer Dedupe-Stufe entfernt).
Erst danach wurde gebaut — mit einem Regressionstest, der heute jede Änderung an der Auflösung absichert: 73.685 gebaute Routen gegen den Index, plus eine Namensstichprobe gegen das JSON-LD der Seiten. Aktueller Stand: 0 Abweichungen.
Die Maßnahmen
- Cache zuerst.
/data/*bekommtCache-Control: public, max-age=3600, stale-while-revalidate=86400— alsroutes-Eintrag, weil nur der nachweislich greift. - Sektionen lazy laden. Die schwere Vergleichssektion auf der Startseite wird per
IntersectionObservererst gemountet, wenn sie in Sichtweite kommt; der Standardfilter lädt eine Kategorie statt aller. - Rechnen verlagern. Eine Übersichtsseite lud 7,9 MB CSVs, um drei Kennzahlen zu berechnen — der verarbeitete Datensatz wurde im Rendering nie benutzt. Die Kennzahlen entstehen jetzt zur Build-Zeit (266 Bytes JSON), mit identischer Rechen-Logik in einem geteilten Modul und CSV-Fallback zur Laufzeit.
- Header als
routes. HSTS mitpreload,X-Frame-Options: SAMEORIGIN,nosniff,Referrer-Policy,Permissions-Policy— und CSP zunächst alsReport-Only. - CSP beobachten statt raten. Verstöße stehen auch im Report-Only-Modus in der Browser-Konsole. Ein headless Durchlauf über 22 Seitentypen förderte genau einen Verstoß zutage: Ein Demoframe der eigenen Accessibility-Toolbar wurde durch
frame-ancestors 'none'blockiert. Korrektur:frame-ancestors 'self'plusX-Frame-Options: SAMEORIGIN— Clickjacking bleibt unterbunden, die eigene Demo funktioniert. Danach: Enforcement, Nachmessung: 0 Verstöße über 18 Seiten. - Tote Konfiguration entfernen. Die wirkungslose
headers-Sektion wurde gelöscht, ihre sinnvollen Cache-Regeln inroutesüberführt.
Ergebnisse
| Metrik | vorher | nachher |
|---|---|---|
| Startseite Transfer | 5.947 KB | 1.606 KB (−73 %) |
| Startseite Requests | 59 | 26 |
| Detailseite Transfer | 42.842 KB | Phase 2 in Arbeit (Ziel ~4 MB) |
| Übersichtsseite Transfer | 9.163 KB | ~1 MB |
| Security-Header | 0 von 6 ausgeliefert | 6 von 6 + CSP enforced |
| CSP-Verstöße | unbekannt | 0 / 18 geprüfte Seiten |
| Weiterleitungs-Schleifen | 1 live | 0 |
Wichtig für die Bewertung: Der Server war zu keinem Zeitpunkt langsam. Die Latenz war immer gut — die Menge war das Problem. Eine Performance-Diskussion, die beim Server anfängt, hätte hier nichts gefunden.
Selbst prüfen
# 1. Was lädt die Seite wirklich? (Transfer statt Requests zählen)
$ curl -sI https://example.org/data/beispiel.txt | grep -i cache-control
$ curl -sI https://example.org/booking | grep -i x-robots-tag
# 2. Werden Weiterleitungsziele ausgeliefert — oder ins Leere?
$ curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' https://example.org/alter-pfad
# 3. Header-Nachweis nach jedem Deploy (nur was hier auftaucht, existiert)
$ curl -sI https://example.org/ | grep -iE 'strict-transport|x-frame|x-content-type|content-security'
Für die Client-Seite lohnt der Blick in die Resource-Timings statt in die Netzwerk-Konsole:
// In der Konsole: wer lädt am meisten?
performance.getEntriesByType('resource')
.map(r => [r.name, r.transferSize])
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
Takeaways
- Messen vor optimieren. Die Frage war nie „ist der Server schnell?", sondern „was lädt der Browser?". Erst die Resource-Timings haben die 42 MB sichtbar gemacht.
- Konfiguration ist keine Auslieferung. Jede Header- und Routing-Regel braucht einen
curl -I-Nachweis nach dem Deploy. - CSP erst beobachten, dann erzwingen. Report-Only plus ein headless Konsolen-Durchlauf findet die eine Regel, die echte Nutzer gebrochen hätte.
- Den naheliegenden Fix simulieren, bevor man ihn baut. Der Kategorie-Fix hätte 322 Seiten beschädigt — die Simulation gegen alle Routen hat es verhindert.
- Rechnen gehört zur Build-Zeit, nicht in den Browser. 7,9 MB Download für drei Zahlen ist kein Caching-Problem, sondern ein Architekturproblem.