Core Web Vitals: Wenn die Seite schnell wirkt, aber die Werte schlecht sind

Ich hatte vor ein paar Monaten eine Seite, die sich verdammt schnell angefühlt hat. Klick, da. Kein Ruckeln, kein Warten, alles sofort sichtbar. Dann habe ich Lighthouse aufgemacht und einen Performance-Score von 54 gesehen. Vierundfünfzig. Mein erster Reflex war: Das Tool spinnt. Mein zweiter war: Vielleicht verstehe ich nicht, was es misst. Und genau das war der Punkt. Was sich schnell anfühlt und was Google als schnell bewertet, sind zwei verschiedene Dinge. Nicht weil Google falsch liegt, sondern weil die Metriken Dinge erfassen, die du als Nutzer nicht bewusst wahrnimmst — die aber trotzdem dein Erlebnis beeinflussen.

Was Core Web Vitals eigentlich messen

Google fasst unter Core Web Vitals drei Metriken zusammen, die seit 2021 als Ranking-Signal gelten. Sie messen nicht, wie schnell dein Server antwortet oder wie groß deine HTML-Datei ist. Sie messen, wie der Nutzer die Seite erlebt — wann er etwas sieht, ob sich das Layout verschiebt und ob die Seite auf Klicks reagiert. Die drei Metriken heißen LCP, CLS und INP. Und jede davon hat ihre eigenen Tücken.

LCP — wann sieht der Nutzer den Hauptinhalt?

Der Largest Contentful Paint misst, wie lange es dauert, bis das größte sichtbare Element im Viewport gerendert ist. Das klingt einfach, ist es aber nicht. Das größte Element ist nicht immer das, was du erwartest. Manchmal ist es das Hero-Bild, manchmal ein großer Textblock, manchmal ein Video-Poster. Du findest heraus, welches Element dein LCP auslöst, indem du in den Chrome DevTools den Performance-Tab öffnest, eine Aufnahme startest und die Seite neu lädst. Im Timing-Bereich siehst du einen Marker namens „LCP“, und wenn du draufklickst, zeigt dir Chrome das entsprechende DOM-Element.

Der häufigste Grund für einen schlechten LCP-Wert ist ein Hero-Bild, das zu spät geladen wird. Der Browser muss erst das HTML parsen, dann das CSS laden, dann das Layout berechnen und dann erst das Bild anfordern. Du kannst diesen Prozess abkürzen, indem du dem Browser schon im Head sagst, dass er das Bild sofort laden soll:

<link rel="preload" as="image" href="/img/hero.webp" fetchpriority="high">

Und das Bild selbst darf auf keinen Fall Lazy Loading haben. Das klingt offensichtlich, aber ich habe es bei drei verschiedenen Projekten gesehen, weil jemand pauschal loading="lazy" auf alle Bilder gesetzt hat. Lazy Loading gehört auf alles außerhalb des Viewports. Das Hero-Bild ist per Definition innerhalb des Viewports — also Finger weg:

<!-- Falsch: Hero-Bild mit Lazy Loading -->
<img src="/img/hero.webp" loading="lazy" alt="Projektübersicht">

<!-- Richtig: Hero-Bild sofort laden, alles andere lazy -->
<img src="/img/hero.webp" fetchpriority="high" alt="Projektübersicht"
     width="1200" height="630">

CLS — warum springt alles?

Der Cumulative Layout Shift misst, wie stark sich Elemente auf der Seite verschieben, nachdem sie bereits sichtbar sind. Du kennst das: Du willst auf einen Link klicken, und genau in dem Moment rutscht der ganze Inhalt nach unten, weil oben eine Werbung eingeblendet wird. Das ist CLS. Und der häufigste Verursacher im Alltag sind Bilder ohne explizite Breiten- und Höhenangabe.

Wenn du ein Bild ohne width und height einbindest, kennt der Browser die Dimensionen erst, nachdem er die Bilddatei geladen hat. Bis dahin reserviert er keinen Platz. Sobald das Bild da ist, schiebt er den gesamten Inhalt darunter nach unten. Das passiert bei jedem einzelnen Bild ohne Dimensionen, und die Verschiebungen addieren sich.

<!-- Gift für CLS: kein Platz reserviert -->
<img src="/img/foto.webp" alt="Wanderweg im Sauerland">

<!-- Korrekt: Browser reserviert Platz vor dem Laden -->
<img src="/img/foto.webp" alt="Wanderweg im Sauerland"
     width="800" height="600" loading="lazy">

Der zweite große CLS-Verursacher sind Webfonts. Wenn dein Font erst spät geladen wird, zeigt der Browser zunächst den Fallback-Font an. Sobald der Webfont dann da ist, tauscht er ihn aus — und weil der Webfont andere Zeichenbreiten hat, verschiebt sich das gesamte Layout. Die Lösung ist font-display: swap in Kombination mit einem Preload für den Font:

@font-face {
    font-family: 'Roboto Flex';
    src: url('/fonts/roboto-flex.woff2') format('woff2');
    font-display: swap;
    font-weight: 100 900;
}
<link rel="preload" as="font" type="font/woff2" crossorigin
      href="/fonts/roboto-flex.woff2">

Das swap sorgt dafür, dass der Fallback-Font sofort angezeigt wird, statt auf den Webfont zu warten. Und das Preload sorgt dafür, dass der Webfont so früh wie möglich geladen wird, damit der Austausch möglichst schnell passiert. Den Shift ganz verhindern kannst du nicht, aber du kannst ihn minimieren, indem der Fallback-Font ähnliche Metriken hat wie dein Webfont.

INP — reagiert die Seite, wenn ich klicke?

Interaction to Next Paint ist seit März 2024 der offizielle Nachfolger von First Input Delay. Der Unterschied: FID hat nur die allererste Interaktion gemessen. INP misst jede Interaktion während der gesamten Sitzung und nimmt den schlechtesten Wert. Das ist deutlich realistischer, denn die erste Interaktion ist selten das Problem — es ist der Button, der nach drei Minuten Scrollen nicht reagiert, weil im Hintergrund ein JavaScript-Timer den Main Thread blockiert.

Alles, was den Main Thread des Browsers blockiert, verschlechtert deinen INP-Wert. Schwere Event-Handler, synchrone API-Calls, aufwändige DOM-Manipulationen. Der wichtigste Hebel ist, wie du deine Skripte einbindest. Viele Entwickler nutzen async, weil es schneller klingt. Aber defer ist in den meisten Fällen die bessere Wahl. Ein Script mit async wird parallel geladen und sofort ausgeführt, sobald es fertig ist — auch mitten im HTML-Parsing. Ein Script mit defer wird parallel geladen, aber erst ausgeführt, wenn das HTML fertig geparst ist. Das bedeutet, dass es den initialen Seitenaufbau nie blockiert:

<!-- async: Ausführung kann HTML-Parsing unterbrechen -->
<script src="/js/analytics.js" async></script>

<!-- defer: Ausführung nach komplettem Parsing -->
<script src="/js/app.js" defer></script>

Messen: Lighthouse, DevTools und die web-vitals-Library

Lighthouse in Chrome gibt dir einen schnellen Überblick, aber du solltest die Zahlen nicht blind optimieren. Lighthouse simuliert ein gedrosseltes Mobilgerät — die Werte sind absichtlich schlechter als das, was deine Nutzer tatsächlich erleben. Nutze Lighthouse als Richtungsweiser, nicht als Endurteil. Wenn du Lighthouse lieber auf der Kommandozeile nutzt, bekommst du reproduzierbare Ergebnisse ohne Browser-Extensions, die das Ergebnis verfälschen:

npx lighthouse https://ruhrcoder.de \
    --only-categories=performance,seo \
    --output=json \
    --output-path=./report.json

Für echte Nutzerdaten im Produktivbetrieb gibt es die web-vitals-Library von Google. Die ist winzig, misst alle drei Core Web Vitals und liefert dir die Werte, die echte Besucher auf echten Geräten erleben. Das ist Gold wert, weil Lighthouse-Werte und Felddaten oft deutlich auseinander liegen:

import { onLCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
    const body = JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating
    });
    navigator.sendBeacon('/api/vitals', body);
}

onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);

Das sendBeacon ist hier kein Zufall. Es sendet die Daten asynchron und blockiert weder den Main Thread noch den Seitenabbau. Perfekt für Metriken, die du im Hintergrund sammeln willst.

Der Aha-Moment: Stabilität statt Geschwindigkeit

Bei dem Projekt, das ich eingangs erwähnt habe, lag das Problem nicht an der Ladezeit. Der Server war schnell, das HTML war klein, die Bilder waren optimiert. Aber drei Dinge waren kaputt: Alle Bilder hatten kein width und height, der Webfont wurde ohne Preload geladen und das Hero-Bild hatte loading="lazy". Drei Fehler, die nichts mit Geschwindigkeit zu tun haben, aber die Metriken zerstören.

Ich habe die drei Sachen gefixt, ohne irgendetwas am Server oder an der Seitengröße zu ändern. Der Lighthouse-Score ging von 54 auf 91. Aber das Interessante war etwas anderes: Die Seite hat sich danach tatsächlich besser angefühlt, obwohl sie nicht schneller geladen hat. Nichts hat mehr geruckelt, nichts ist mehr gesprungen, der Font war sofort da. Es war keine Geschwindigkeitsoptimierung — es war eine Stabilitätsoptimierung. Und genau das messen die Core Web Vitals: Nicht wie schnell deine Seite ist, sondern wie stabil und vorhersehbar sie sich verhält.

Wenn du also das nächste Mal einen schlechten Lighthouse-Score siehst, such nicht als Erstes nach dem CDN oder dem Caching-Plugin. Öffne den Performance-Tab, schau dir an, was dein LCP-Element ist, ob du Layout Shifts hast und was den Main Thread blockiert. Die Lösung ist fast immer einfacher, als du denkst — und hat fast nie etwas mit Geschwindigkeit zu tun.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu