~/blog/42mb-pro-seitenaufrufPOSTED
von Shady Nathan Tawfik · 13. Oktober 2026 · 6 min

Ein Seitenaufruf, 42,8 MB Daten — und zwölf Security-Header, die nie ausgeliefert wurden

Eine Static-SPA mit 76.000 vorgerenderten Seiten lud bei jedem Detailaufruf 42,8 MB Rohdaten in den Browser. Parallel stellte sich heraus: Die Header-Regeln der Deployment-Konfiguration waren wirkungslos. Ein Messbericht über zwei Bugs mit derselben Wurzel — Bequemlichkeit beim Routing.

Interaktiv[blog-2.0]

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:

SeiteTransferRequests
Detailseite (Arzt)42.842 KB103
Detailseite (Restaurant)42.842 KB103
Startseite5.947 KB59
Analysen-Übersicht9.163 KB27
Buchungsseite1.011 KB16

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:

  1. Der Cache konnte nicht greifen. Die CSVs werden zusätzlich in localStorage gehalten (TTL eine Stunde). Quota: 5–10 MB. Ein 42-MB-Datensatz passt nicht hinein, jeder Schreibversuch läuft in QuotaExceeded — und der nächste Seitenaufruf lädt alles neu.
  2. Fehlende HTTP-Cache-Regel. /data/* wurde mit max-age=0, must-revalidate ausgeliefert. 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:

~/code/bash bash
$ 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

  1. Cache zuerst. /data/* bekommt Cache-Control: public, max-age=3600, stale-while-revalidate=86400 — als routes-Eintrag, weil nur der nachweislich greift.
  2. Sektionen lazy laden. Die schwere Vergleichssektion auf der Startseite wird per IntersectionObserver erst gemountet, wenn sie in Sichtweite kommt; der Standardfilter lädt eine Kategorie statt aller.
  3. 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.
  4. Header als routes. HSTS mit preload, X-Frame-Options: SAMEORIGIN, nosniff, Referrer-Policy, Permissions-Policy — und CSP zunächst als Report-Only.
  5. 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' plus X-Frame-Options: SAMEORIGIN — Clickjacking bleibt unterbunden, die eigene Demo funktioniert. Danach: Enforcement, Nachmessung: 0 Verstöße über 18 Seiten.
  6. Tote Konfiguration entfernen. Die wirkungslose headers-Sektion wurde gelöscht, ihre sinnvollen Cache-Regeln in routes überführt.

Ergebnisse

Metrikvorhernachher
Startseite Transfer5.947 KB1.606 KB (−73 %)
Startseite Requests5926
Detailseite Transfer42.842 KBPhase 2 in Arbeit (Ziel ~4 MB)
Übersichtsseite Transfer9.163 KB~1 MB
Security-Header0 von 6 ausgeliefert6 von 6 + CSP enforced
CSP-Verstößeunbekannt0 / 18 geprüfte Seiten
Weiterleitungs-Schleifen1 live0

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

~/code/bash bash
# 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:

~/code/js js
// 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

  1. 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.
  2. Konfiguration ist keine Auslieferung. Jede Header- und Routing-Regel braucht einen curl -I-Nachweis nach dem Deploy.
  3. CSP erst beobachten, dann erzwingen. Report-Only plus ein headless Konsolen-Durchlauf findet die eine Regel, die echte Nutzer gebrochen hätte.
  4. 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.
  5. 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.
Shady Nathan Tawfik

Gründer & Cyber IT Security Engineer in Bielefeld. Offensive Security, Audits und Fullstack-Entwicklung. Mehr auf

Nur zu Bildungszwecken. Alle reproduzierbaren Befehle zielen auf meine eigene Lab-Umgebung (localhost), nie auf ein laufendes System. © 2026