DarkWolfCave
webauftritt

Lighthouse LCP: 4,7 Sekunden, die es nie gab

Wolf sitzt vor einem Monitor mit rotem Performance-Messwert und einer Stoppuhr daneben
DarkWolf MaskottchenKI-BildGeneriert mit Gemini

Lighthouse sagte 4,7 Sekunden. Gemessen waren 365 Millisekunden.

Ich habe zwei Tage in die Performance eines Projekts gesteckt und dabei die falsche Zahl gejagt. Der Artikel zeigt, warum der LCP-Wert in Lighthouse keine Ladezeit ist, wie du das in deinem eigenen Bericht nachweist und welche drei Maßnahmen am Ende 356 KB pro Seitenaufruf gespart haben.

DarkWolfCave.de

Lighthouse, PageSpeed, Core Web Vitals: wer misst hier was

Drei Begriffe, die zusammengehören, aber Verschiedenes bezeichnen.

Lighthouse ist das Mess-Werkzeug. Es steckt fest eingebaut in Chrome, im Tab “Lighthouse” der Entwicklertools. Es lädt deine Seite in einem frischen Browserprofil, zeichnet dabei alles auf und vergibt am Ende Punkte von 0 bis 100 für Performance, Barrierefreiheit, Best Practices und SEO.

PageSpeed Insights ist dieselbe Software, nur auf Googles Servern statt auf deinem Rechner. Du gibst unter pagespeed.web.dev eine URL ein und bekommst denselben Bericht, gemessen von außen.

Core Web Vitals sind drei Werte aus diesem Bericht, die Google für das Ranking heranzieht:

WertWas er beschreibtZielwert
LCP (Largest Contentful Paint)Wann das größte sichtbare Element fertig ist, meist ein Bild oder eine Überschriftunter 2,5 s
CLS (Cumulative Layout Shift)Wie stark der Inhalt beim Laden noch herumspringtunter 0,1
INP (Interaction to Next Paint)Wie schnell die Seite auf Klicks reagiertunter 200 ms

In diesem Artikel geht es um LCP, den Wert, der bei mir rot war.

ℹ️ Hinweis: Für das Ranking zählen am Ende weder Lighthouse noch PageSpeed, sondern die Felddaten echter Besucher aus dem Chrome UX Report. Die Laborwerte sind ein Näherungswert dafür. Bei neuen Seiten ohne Traffic gibt es noch gar keine Felddaten, dann bleibt nur das Labor.

VIP Support
Wolf Support Avatar

Du wirst hier einen groben Überblick finden.
Allerdings biete ich dir auch noch etwas mehr Support an:

  • Du benötigst persönlichen Support
  • Du möchtest von Beginn an Unterstützung bei deinem Projekt
  • Du möchtest ein hier vorgestelltes Plugin durch mich installieren und einrichten lassen
  • Du würdest gerne ein von mir erstelltes Script etwas mehr an deine Bedürfnisse anpassen

Für diese Punkte und noch einiges mehr habe ich einen limitierten VIP-Tarif eingerichtet.

Falls der Tarif gerade nicht verfügbar ist, kontaktiere mich auf Discord!

An die Rohdaten kommen: drei Wege

Die Punktzahl in der bunten Oberfläche reicht nicht weit. Die interessanten Zahlen stehen im JSON-Bericht, und dort finden sich Werte, die in der Oberfläche überhaupt nicht auftauchen. Einer davon ist observedLargestContentfulPaint, und der klärt den Fall weiter unten.

Weg 1: Chrome DevTools, für einzelne Messungen

Entwicklertools öffnen mit F12, Tab Lighthouse, Modus “Navigation”, Gerät “Mobile”, Haken bei Performance, dann Analyze page load.

Wichtig dabei: im Inkognito-Fenster messen. Browser-Erweiterungen laden ihren eigenen Code in jede Seite, und Lighthouse zählt den mit. Bei mir waren das 53 KB, die der Bericht als “ungenutztes JavaScript meiner Seite” auswies.

Den fertigen Bericht speicherst du über das Drei-Punkte-Menü oben rechts im Lighthouse-Panel mit Save as JSON. Die HTML-Variante sieht hübscher aus, enthält aber nur die Darstellung, nicht die Rohwerte.

Weg 2: Die API, für Messreihen

Wenn du mehrere Seiten oder denselben Stand mehrfach messen willst, wird das Klicken mühsam. Die PageSpeed-Insights-API liefert dasselbe JSON auf Zuruf. Dafür brauchst du einen Schlüssel.

⚠️ Wichtig: Ohne Schlüssel antwortet die API nicht etwa mit einem kleinen Gratis-Kontingent, sondern mit 429 RESOURCE_EXHAUSTED und "quota_limit_value": "0". Wiederholen hilft nicht, das Tageslimit für anonyme Zugriffe ist null. Mit Schlüssel sind es 25.000 Abfragen pro Tag, kostenlos.

So kommst du an den Schlüssel:

  1. In der Google Cloud Console ein Projekt auswählen oder neu anlegen
  2. Unter APIs und Dienste → Bibliothek nach PageSpeed Insights API suchen und aktivieren
  3. Unter Anmeldedaten → Anmeldedaten erstellen → API-Schlüssel einen Schlüssel anlegen
  4. Beim Schlüssel unter API-Einschränkungen genau diese eine API auswählen

Liegt im selben Projekt noch ein anderer Schlüssel, etwa für ein Sprachmodell, könnte ein durchgesickerter PSI-Schlüssel ohne diese Einschränkung auch dort Anfragen stellen, und das kostet dann Geld. Die Anwendungseinschränkung dagegen lässt du auf “Keine”: Die Option Websites filtert nach HTTP-Referrer, den schickt nur ein Browser, und curl fällt damit raus.

Den Schlüssel legst du als Umgebungsvariable ab:

# in ~/.zshrc bzw. ~/.bashrc
export PSI_API_KEY="DEIN_SCHLUESSEL"

💡 Tipp: zsh liest ~/.zshrc nur bei interaktiven Shells. In einem Skript ist die Variable leer, und der Aufruf scheitert mit einer irreführenden Fehlermeldung. Entweder legst du den Export in ~/.zshenv, die bei jedem Start gelesen wird, oder du stellst im Skript ein source ~/.zshrc voran.

Die Abfrage selbst ist ein einzelner Aufruf. Die URL muss kodiert werden, sonst verschluckt die API alles ab dem ersten Schrägstrich:

URL="https://example.com/"
curl -sS --max-time 180 -o ergebnis.json \
  "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$URL")&strategy=mobile&category=performance&key=${PSI_API_KEY}"

strategy=mobile misst mit gedrosselter Mobilverbindung, strategy=desktop ohne. Google bewertet Seiten seit dem Mobile-First-Indexing nach der mobilen Variante, deshalb ist das der Standardfall.

Weg 3: Die Search Console, für echte Nutzerdaten

Unter Core Web Vitals zeigt die Google Search Console, wie deine Seite bei echten Besuchern abschneidet. Das sind die Zahlen, die fürs Ranking zählen. Sie brauchen allerdings Traffic und bilden ein Zeitfenster von 28 Tagen ab. Bei einer frischen Seite steht dort erstmal nichts.

Den Bericht lesen

Mit jq holst du die wichtigsten Werte heraus. Die erste Abfrage stellt den hochgerechneten dem beobachteten Wert gegenüber:

jq -r '.lighthouseResult as $l |
  "LCP simuliert : \($l.audits["largest-contentful-paint"].numericValue) ms",
  "LCP beobachtet: \($l.audits.metrics.details.items[0].observedLargestContentfulPaint) ms",
  "CLS           : \($l.audits["cumulative-layout-shift"].displayValue)",
  "TBT           : \($l.audits["total-blocking-time"].numericValue) ms",
  "Score         : \(($l.categories.performance.score*100)|round)"' ergebnis.json

Das Transfergewicht nach Ressourcentyp zeigt, wo die Bytes liegen:

jq -r '[.lighthouseResult.audits["network-requests"].details.items[]
  | {typ: .resourceType, bytes: (.transferSize // 0)}]
  | group_by(.typ)
  | map({typ: .[0].typ, kb: ((map(.bytes) | add) / 1024 | floor)})
  | sort_by(-.kb)[] | "\(.typ): \(.kb) KB"' ergebnis.json

Der Befund, der nicht zum Gefühl passte

Bei einem meiner Projekte zeigte PageSpeed Insights einen Largest Contentful Paint von 4,7 Sekunden auf der Startseite. Der Zielwert liegt bei 2,5 Sekunden, also weit daneben. Gleichzeitig fühlte sich die Seite im Browser sofort da an, und der Time to First Byte lag bei 0,15 Sekunden.

Rote Werte im Test, Seite beim Aufruf sofort sichtbar: Meine erste Vermutung war ein Rendering-Problem. Die zweite, dass JavaScript den Hauptthread blockiert. Beide waren falsch, und das Aufräumen dieser Irrwege hat mich mehr Zeit gekostet als der eigentliche Fix.

Was Lighthouse tatsächlich misst

Lighthouse arbeitet standardmäßig mit simulated throttling. Die Seite wird dabei mit voller Geschwindigkeit geladen, und erst danach rechnet ein Modell namens Lantern aus, wie lange das Ganze über eine langsame Mobilfunkverbindung gedauert hätte.

Der angezeigte LCP ist also eine Schätzung, keine Stoppuhr. Diese Schätzung hängt fast vollständig an der übertragenen Datenmenge. Das erklärt, warum eine Seite mit gutem TTFB und schnellem Rendering trotzdem einen schlechten Wert bekommt: Es geht nicht darum, wie schnell die Seite bei dir war, sondern wie viel sie über eine gedrosselte Leitung hätte schleppen müssen.

ℹ️ Hinweis: Für das Ranking zählen weder die simulierten noch die beobachteten Laborwerte, sondern die Felddaten echter Nutzer aus dem Chrome UX Report. Bei neuen Seiten ohne nennenswerten Traffic existieren die noch gar nicht, dort siehst du in der Search Console nur eine Lücke.

Die beobachteten Werte im JSON

Der Beweis steckt im Bericht selbst. Lighthouse legt neben jeder hochgerechneten Metrik auch den beobachteten Wert ab. Im JSON findest du ihn unter audits.metrics.details.items[0]:

"firstContentfulPaint": 1955,
"largestContentfulPaint": 4730,
"observedFirstContentfulPaint": 365,
"observedLargestContentfulPaint": 365

Der gemessene Wert lag bei 365 Millisekunden, der hochgerechnete bei 4730. Faktor 13.

Noch aufschlussreicher ist, dass observedFirstContentfulPaint und observedLargestContentfulPaint identisch sind. Im echten Messlauf erschien der größte Textblock im selben Moment wie das erste Pixel. Es gab also keine Verzögerung beim Rendern, kein nachladendes Bild, keinen Font-Wechsel. Die 2,7 Sekunden Abstand, die ich im Bericht sah, existierten nur zwischen zwei geschätzten Zahlen.

Meine drei falschen Fährten

Bevor ich das verstanden hatte, habe ich nacheinander drei Hypothesen verfolgt. Alle drei klangen plausibel, alle drei waren falsch.

Die Schrift verzögert das Rendern. Naheliegend, weil Text mit font-display: swap erst in der Ersatzschrift erscheint und beim Eintreffen der Webfont neu gezeichnet wird. Das kann den LCP-Zeitpunkt tatsächlich verschieben. Widerlegt durch die beobachteten Werte: Wenn FCP und LCP beide bei 365 ms liegen, gibt es kein zweites Zeichnen.

JavaScript blockiert den Hauptthread. Auch das passte zunächst, denn eine Unterseite zeigte 956 ms Total Blocking Time. Die Startseite sprach dagegen: Dort lagen nur 47 ms Blocking Time bei trotzdem hohem LCP. Ein blockierter Hauptthread sieht anders aus.

Das Gesamtgewicht der Seite ist schuld. Fast richtig, aber ich habe es zunächst falsch geprüft. Ich verglich das Gesamtgewicht mehrerer Seiten mit ihrem LCP und fand keine Korrelation: Die schwerste Seite hatte den zweitbesten Wert. Erst später wurde klar, dass nicht das Gesamtgewicht zählt, sondern das Gewicht im kritischen Pfad.

💡 Tipp: Bevor du eine Optimierung baust, prüfe im JSON, ob die beobachteten Werte überhaupt ein Problem zeigen. Steht dort eine dreistellige Millisekundenzahl, ist deine Seite schnell und du optimierst gegen ein Modell, nicht gegen die Realität.

Was tatsächlich half

Wenn die Metrik am Transfergewicht hängt, ist die einzige wirksame Maßnahme: weniger Bytes. Drei Posten haben den Unterschied gemacht.

Die Schriftdatei bestand zu 82 Prozent aus Ballast

Inter-Variable.woff2 war mit 344 KB die größte Einzelressource der Seite, größer als alle Skripte und das CSS zusammen, und wurde auf jeder Seite mit hoher Priorität geladen.

Ein Blick in die Datei mit fonttools zeigte 2852 Zeichen. Davon lagen 525 im lateinischen Bereich. Die übrigen 2327 waren griechische und kyrillische Buchstaben, eingekreiste Ziffern, Bruchzahlen und Kombinationszeichen, die eine deutschsprachige Seite nie anzeigt.

Nach dem Subsetting auf den lateinischen Zeichensatz blieben 61 KB übrig, bei identischer Darstellung. Beide variablen Achsen und das Kerning sind erhalten:

# fonttools in einer temporären Umgebung installieren
python3 -m venv /tmp/ft && /tmp/ft/bin/pip install -q fonttools brotli

# Latin Basic + Latin-1 + Latin Extended-A + Interpunktion + Euro + ẞ
/tmp/ft/bin/pyftsubset DEINE-SCHRIFT.woff2 \
  --output-file=schrift-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,clig,ccmp,locl,mark,mkmk' \
  --unicodes="U+0020-007E,U+00A0-00FF,U+0100-017F,U+1E9E,U+2000-206F,U+20AC,U+2122,U+2212,U+FB00-FB04"

Die Wahl bei --layout-features ist der eigentliche Größenhebel. Mit '*' landen sämtliche OpenType-Funktionen in der Datei, also auch Kapitälchen, Bruchdarstellung und Stilsets. Das ergab bei mir 141 KB. Beschränkt auf das typografisch Nötige sind es 61 KB.

⚠️ Wichtig: U+1E9E ist das große Eszett (ẞ). Es liegt in Latin Extended Additional und fehlt deshalb in fast jedem Standard-Subset. Bei mir fiel es beim ersten Versuch heraus, und im Versalsatz wäre “STRAẞE” auf die Ersatzschrift gefallen. Prüfe das Ergebnis immer gegen einen Testtext mit Umlauten, ẞ, typografischen Anführungszeichen und Euro-Zeichen.

Ein Bild in sechsfacher Auflösung

Das Emblem auf der Startseite lag in 1400 mal 1153 Pixeln vor. Dargestellt wurde es mit 192 Pixeln Höhe, also rund 233 Pixeln Breite. Selbst für ein 3x-Display reichen 816 Pixel.

Auf 900 Pixel verkleinert und mit cwebp -q 88 neu kodiert, sank die Datei von 166 auf 89 KB. Der Qualitätsverlust ist rechnerisch nicht der Rede wert: PSNR 46,3 dB, deutlich über der 40-dB-Schwelle, ab der eine Kompression als verlustfrei durchgeht.

⚠️ Wichtig: Achte auf den Alpha-Kanal. Ich hatte als Quelle eine JPG-Fassung im Originale-Ordner liegen, und JPEG kann per Definition keine Transparenz speichern. Das Ergebnis sah auf hellem Hintergrund identisch aus, im Dark Mode stand plötzlich ein weißer Kasten um das freigestellte Logo. Aufgefallen ist es erst im Produktivbetrieb. Nach jeder Konvertierung also webpinfo datei.webp | grep -i alpha prüfen, dort muss Alpha: 1 stehen.

Ein Banner, der die halbe Seite verschob

Nebenbei fiel ein Cumulative Layout Shift von 0,385 auf, bei einem Grenzwert von 0,1. Die Ursache war ein Hinweisbanner am Seitenkopf, der serverseitig unsichtbar war und erst nach dem Mounten per JavaScript eingeblendet wurde. Beim Erscheinen schob er den gesamten Inhalt nach unten.

Der Beweis war ein Zufallsfund im JSON: Der Shift-Wert 0.24105786755771569 war auf mehreren völlig verschiedenen Seiten bis zur letzten Nachkommastelle identisch. So etwas kann keine seitenspezifische Ursache haben.

Die Lösung war die einfachste von allen: Der Banner ist jetzt nicht mehr wegklickbar und wird serverseitig mitgerendert. Damit entfällt der gespeicherte Zustand komplett, die Komponente wurde um 73 Zeilen kürzer, und CLS fiel auf 0,006.

Du willst Web-Performance nicht selbst einrichten? Ich übernehme das für dich —schau dir meine Services an

Zwei Messfallen, in die ich getreten bin

Die API cacht rund 30 Minuten. Ich wollte die Streuung prüfen und rief dieselbe URL fünfmal hintereinander ab. Alle fünf Antworten waren identisch, bis auf die letzte Nachkommastelle. Kein Wunder: Es war eine einzige Messung, viermal aus dem Cache. Erkennbar ist das nur an einem Feld:

jq -r '.analysisUTCTimestamp' ergebnis.json

Sind die Zeitstempel mehrerer Antworten identisch, hast du keine unabhängigen Läufe. Für echte Messreihen musst du die Abfragen zeitlich verteilen oder Lighthouse lokal in den Chrome DevTools laufen lassen.

Der Performance-Score taugt nicht als Beleg. Ich habe an einem Tag am identischen Seitenstand die Werte 57, 78, 84 und 77 gemessen. Wer damit einen Erfolg belegen will, belegt Zufall. Belastbar sind das Transfergewicht, das du jederzeit mit curl nachprüfen kannst, und Werte, die über mehrere unabhängige Läufe stabil bleiben.

Dazu kommt: PSI und die lokalen DevTools messen nicht dasselbe. Für denselben Seitenstand bekam ich über die API 855 KB, LCP 4,4 s und Score 77, über Chrome DevTools dagegen 764 KB, LCP 2,6 s und Score 96. Andere Drosselungsannahmen, anderer Messstandort. Vergleiche deshalb immer nur innerhalb einer Methode.

Alle Maßnahmen im Überblick

Gemessen mit lokalem Chrome-Lighthouse, gleicher Rechner, gleiches Netz, vorher und nachher:

vorhernachher
Transfergewicht Startseite1115 KB764 KB
LCP4,7 s2,6 s
First Contentful Paint2,0 s1,7 s
Cumulative Layout Shift0,2430,006
Performance-Score8196

Die Schrift allein brachte 283 KB auf jeder einzelnen Seite. Das Bild weitere 77 KB auf den beiden Seiten, auf denen es vorkommt. Zusammen ein Drittel des Seitengewichts, ohne dass sich am Aussehen etwas geändert hat.

Was ich bewusst nicht angefasst habe: Rund 424 KB JavaScript in 118 Anfragen, also die Hälfte des verbliebenen Gewichts. Eine Analyse der Chunks zeigte, dass der Löwenanteil aus den Komponenten-Primitiven der verwendeten UI-Bibliothek stammt. Die kommen mit, sobald die Seite einen Button oder einen Tooltip benutzt, und lassen sich nicht selektiv laden. Theoretisch wären dort 23 KB zu holen, bei echtem Risiko für kaputte Bedienelemente. Das Verhältnis stimmt nicht.

Noch ein Detail: Von den 215 KB, die Lighthouse als ungenutztes JavaScript meldete, entfielen 53 KB auf eine Chrome-Erweiterung in meinem Browser. Die hatte mit der Seite nichts zu tun. Miss im Inkognito-Fenster, sonst optimierst du gegen deine eigenen Plugins.

Selbst nachmessen

Wenn du einen roten LCP-Wert vor dir hast, lohnt dieser Ablauf:

  1. Bericht als JSON speichern, in den DevTools über das Drei-Punkte-Menü im Lighthouse-Panel
  2. observedLargestContentfulPaint heraussuchen und mit dem angezeigten Wert vergleichen
  3. Ist der beobachtete Wert niedrig, liegt es am Transfergewicht, nicht am Rendering
  4. Das Gewicht nach Ressourcentyp aufschlüsseln und beim größten Posten anfangen

Bei mir war dieser größte Posten eine Schriftdatei, danach ein Bild in unnötiger Auflösung. Beides war an einem Nachmittag erledigt, ohne sichtbare Änderung.

Das Projekt, an dem ich das gemessen habe, ist PfotenGuard, ein Werkzeug zur Futterdokumentation für Hundehalter. Die Zahlen im Artikel stammen alle von dort.

Wer mit WordPress arbeitet, findet in meinem Vergleich der Caching-Plugins die passenden Werkzeuge für dieselbe Aufgabe, und der Test von Rank Math deckt die On-Page-Seite ab. Wenn du wissen willst, wie ein moderner Stack ohne PHP aussieht, habe ich das im Artikel Von PHP zu Nuxt beschrieben.

FAQ - Frequently Asked Questions DarkWolfCave
DarkWolf hilft bei FAQs

Häufig gestellte Fragen

Warum zeigt Lighthouse einen viel schlechteren LCP als mein eigenes Gefühl beim Laden?
Weil Lighthouse die Seite ungedrosselt lädt und die Metriken anschließend auf ein langsames Mobilfunknetz hochrechnet. Der tatsächlich beobachtete Wert steht als observedLargestContentfulPaint im Bericht und ist oft um ein Vielfaches niedriger.
Was ist der Unterschied zwischen observed und simulated in Lighthouse?
observed*-Werte stammen aus dem echten Messlauf ohne Drosselung. Die Hauptmetriken (LCP, FCP, Speed Index) sind daraus hochgerechnete Schätzungen für langsame Verbindungen. Für Rankings zählen am Ende weder die einen noch die anderen, sondern die Felddaten echter Nutzer aus dem Chrome UX Report.
Brauche ich für die PageSpeed-Insights-API einen Schlüssel?
Ja. Ohne Schlüssel antwortet die API mit 429 RESOURCE_EXHAUSTED und einem Tageskontingent von null. Der Schlüssel ist kostenlos und in wenigen Minuten in der Google Cloud Console erstellt.
Warum liefert die PSI-API bei mehreren Aufrufen identische Werte?
Die API cacht Ergebnisse etwa 30 Minuten pro URL und Strategie. Erkennbar ist das am Feld analysisUTCTimestamp: Ist es bei mehreren Antworten identisch, war es dieselbe Messung.
Wie viel bringt Font-Subsetting?
In meinem Fall 283 KB pro Seitenaufruf. Die Inter-Variable-Schrift enthielt 2852 Zeichen, davon 2327 griechische, kyrillische und typografische Sonderzeichen, die eine deutschsprachige Seite nie darstellt. Nach dem Subsetting waren es 61 KB statt 344 KB.
Welcher Unicode-Bereich fehlt in fast jedem Latin-Subset?
U+1E9E, das große Eszett (ẞ). Es liegt in Latin Extended Additional und fällt aus den üblichen Latin-Bereichen heraus. Im Versalsatz fällt der Text dann auf die Ersatzschrift zurück.

Kommentare

URLs werden automatisch verlinkt
Kommentare werden geladen...