← Alle Beiträge

Von 76 auf 100: Ein perfekter Lighthouse-Score

Kurzfassung: Eine persönliche Portfolio-Website ging von einem mobilen Lighthouse-Performance-Score von 76 mit 0,493 CLS zu einem perfekten 100/100/100/100 in allen Kategorien. Der Weg dorthin deckte subtile CSS-Ladeprobleme, Regex-Bugs und eine besonders hinterhältige CSS-Variablen-Überschreibung auf, die Layout-Verschiebungen auf Mobilgeräten verursachte.


Der Ausgangspunkt

Die Website war ein FastAPI + Jinja2-Portfolio mit HTMX und Alpine.js für Interaktivität. Das erste mobile Lighthouse-Audit ergab:

Metrik Score
Performance 76
Barrierefreiheit 91
Best Practices 100
SEO 100
CLS 0,493

Dieser CLS-Wert ist verheerend. Google betrachtet alles über 0,1 als „schlecht”. Bei 0,493 sprang die Seite während des Ladens sichtbar hin und her.


Phase 1: Schnelle Verbesserungen der Barrierefreiheit

Bevor ich mich der Performance widmete, berinigte ich zunächst Barrierefreiheitsprobleme, um eine Baseline zu schaffen:

Formular-Labels

Dekorative Spans wurden durch korrekte <label>-Elemente ersetzt:

<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">

<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">

Kontrastverhältnisse

Footer-Überschriften und Formular-Labels verwendeten --color-text-tertiary (40 % Deckkraft Weiß). Diese wurden auf --color-text-secondary (65 % Deckkraft) angehoben, um die WCAG-AA-Kontrastanforderung von 4,5:1 zu erfüllen. Kontraste richtig zu setzen, gehört zu einem umfassenderen Farbsystem — wahrnehmungsbezogene Gleichmäßigkeit ist eine Frage der Barrierefreiheit, nicht nur der Ästhetik.

Redundanter Alt-Text

Social-Media-Icons hatten Alt-Texte wie „LinkedIn icon”, obwohl sie sich in Links befanden, die bereits aria-labels besaßen. Diese wurden auf alt="" mit aria-hidden="true" geändert, um Wiederholungen durch Screenreader zu vermeiden.

Identischer Linktext

Mehrere „View Case Study”-Links waren für Screenreader nicht unterscheidbar. Es wurden aria-label-Attribute mit Projektnamen hinzugefügt:

<a href="/work/introl" aria-label="View Introl Branding case study">
  View Case Study
</a>

Ergebnis: Die Barrierefreiheit stieg von 91 auf 100.


Phase 2: Das Render-blockierende CSS-Problem

Lighthouse zeigte eine „Element render delay” von 2.460 ms in der LCP-Aufschlüsselung. Der Übeltäter: eine synchrone CSS-Datei, die den ersten Paint blockierte.

<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">

Der Browser musste das gesamte 25-KB-Stylesheet herunterladen und parsen, bevor er irgendetwas rendern konnte.

Die Lösung: Critical CSS + asynchrones Laden

Schritt 1: Kritisches (Above-the-Fold) CSS in eine separate Datei extrahieren und inline im <head> einbetten.

Schritt 2: Das vollständige Stylesheet asynchron mit dem media="print"-Trick laden:

<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>

<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
      media="print" onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>

Das media="print"-Attribut teilt dem Browser mit: „Dieses Stylesheet wird nicht für die Bildschirmdarstellung benötigt”, sodass es den Render-Vorgang nicht blockiert. Sobald es geladen ist, schaltet der onload-Handler auf media="all" um.

Das Critical-CSS-Extraktionsskript

Ich schrieb ein Python-Skript, das automatisch kritisches CSS für Above-the-Fold-Komponenten extrahiert:

CRITICAL_PREFIXES = [
    ".container",
    ".header",
    ".nav",
    ".hero",
    ".personal-photos",
]

def extract_critical_css(css_content: str) -> str:
    # Strip comments to prevent regex pollution
    css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

    # Extract :root variables
    # Extract base reset rules
    # Extract component rules by prefix
    # Extract media queries containing critical selectors
    ...

Ergebnis: Die LCP-Element-Render-Verzögerung sank von 2.460 ms auf ca. 300 ms.


Phase 3: Der CLS-Albtraum beginnt

Nach der Implementierung des asynchronen CSS wurde der CLS tatsächlich schlimmer — er sprang auf 0,119. Lighthouse zeigte <main> als das sich verschiebende Element an.

Bug Nr. 1: CSS-Kommentare verunreinigen den Regex

Das Extraktionsskript verwendete einen Regex zum Abgleich von Selektoren:

rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"

Problem: CSS-Kommentare wurden vor der Extraktion nicht entfernt. Ein Kommentar wie:

/* Hero Section - Editorial */
.hero { ... }

Führte dazu, dass der Regex von „Hero Section” bis zur öffnenden geschweiften Klammer matchte und fehlerhafte Selektoren erzeugte, die die Präfix-Prüfung nicht bestanden. Kritische .hero-Styles wurden stillschweigend verworfen.

Lösung: Kommentare entfernen, bevor Regex-Operationen ausgeführt werden:

css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

Bug Nr. 2: Media-Query-Regeln als eigenständige Regeln extrahiert

Der Regex matchte Regeln innerhalb von Media Queries und extrahierte sie als eigenständige Regeln:

/* Full CSS structure */
.hero__title { font-size: 5rem; }           /* Desktop */

@media (max-width: 768px) {
  .hero__title { font-size: 1.875rem; }     /* Mobile */
}

Das Skript extrahierte beide Regeln auf oberster Ebene:

/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; }  /* Overrides desktop! */

Auf Mobilgeräten wurde der Titel mit der mobilen Größe aus dem Critical CSS gerendert und … blieb dort, weil das vollständige CSS dieselben Regeln enthielt. Auf dem Desktop wurde jedoch die mobile Überschreibung fälschlicherweise angewendet.

Lösung: Media-Query-Positionen verfolgen und Regeln innerhalb dieser überspringen:

# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
    media_ranges.append((match.start(), match.end()))

def is_inside_media_query(pos: int) -> bool:
    return any(start <= pos < end for start, end in media_ranges)

# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
    if is_inside_media_query(match.start()):
        continue  # Will be included with full media query block
    # ... extract rule

Phase 4: Die 100vh-Hypothese

Der CLS lag immer noch bei 0,116. Theorie: Wenn nichts unterhalb des sichtbaren Bereichs beim ersten Paint sichtbar ist, kann es nicht zum CLS beitragen.

Ich änderte den Hero-Bereich von 85vh auf 100vh:

.hero {
  /* 100vh ensures nothing below fold is visible on initial paint */
  min-height: 100vh;
}

Ergebnis: Keine Veränderung. CLS weiterhin 0,116. Die Verschiebung fand innerhalb des sichtbaren Bereichs statt.


Phase 5: Die heiße Spur

Der Lighthouse-Filmstreifen zeigte, wie sich der Hero-Text zwischen den Frames sichtbar verschob. Kein Einblenden — horizontales Verschieben.

Detailanalyse, welche Styles die horizontale Positionierung beeinflussen: - .hero__content verwendet padding: 0 var(--gutter) - --gutter ist in :root als 48px definiert

Dann fand ich es:

/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
  :root {
    --gutter: var(--spacing-md);  /* 24px */
  }
}

Der Ablauf auf Mobilgeräten:

  1. Critical CSS wird geladen: --gutter: 48px
  2. Der Hero wird mit 48px seitlichem Padding gerendert
  3. Das vollständige CSS wird asynchron geladen
  4. Die Media Query setzt --gutter: 24px
  5. Das Hero-Padding schrumpft von 48px auf 24px
  6. Text fließt um und verschiebt sich = CLS 0,116

Die Lösung

:root-Media-Queries als Teil des Critical CSS extrahieren:

# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
    critical_rules.append(match.group())

Jetzt enthält das Critical CSS:

:root { --gutter: 48px; /* ... */ }

@media (max-width: 768px) {
  :root { --gutter: var(--spacing-md); }
}

Auf Mobilgeräten wird von Anfang an mit dem korrekten 24px-Gutter gerendert. Wenn das vollständige CSS geladen wird, ist --gutter bereits 24px — keine Änderung, keine Verschiebung.


Phase 6: CSP und Alpine.js

Ein letztes Problem: Alpine.js warf Konsolenfehler wegen Verstößen gegen die Content Security Policy. Alpine verwendet intern dynamische Ausdrucksauswertung.

# In security headers middleware
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

Die 'unsafe-eval'-Direktive ist für Alpine.js’ Expression-Parsing erforderlich. Die Alternative wäre der CSP-kompatible Build von Alpine, der jedoch einige Einschränkungen mit sich bringt.


Endergebnisse

Metrik Vorher Nachher
Performance 76 100
Barrierefreiheit 91 100
Best Practices 100 100
SEO 100 100
CLS 0,493 0,033
LCP 2,6 s 0,8 s

Der Beweis

Desktop-Audit mit perfekten Scores in allen vier Kategorien:

Lighthouse-Desktop-Audit mit 100/100/100/100 Scores

Mobiles Audit — das wichtigere — ebenfalls mit Bestnoten:

Lighthouse-Mobil-Audit mit 100/100/100/100 Scores


Wichtige Erkenntnisse

1. CSS-Variablen können CLS verursachen

Media-Query-Überschreibungen von CSS Custom Properties sind im „Computed Styles”-Panel der DevTools unsichtbar — Sie sehen nur den finalen Wert. Wenn Ihr Critical CSS keine Variablen-Überschreibungen enthält, wird sich das Layout verschieben, sobald das vollständige Stylesheet geladen wird. Besonders gefährlich ist das in Typografiesystemen, in denen Schriftgrößen- und Zeilenhöhen-Variablen durch jedes einzelne Textelement kaskadieren.

Auch die Farbwissenschaft, auf der das wahrnehmungsbasierte Farbsystem der Website aufbaut, spielte eine Rolle: Zu den CSS-Variablen-Überschreibungen, die den CLS verursachten, zählten neben Abstandsvariablen auch Farbvariablen, und die Korrektur griff für beide.

2. Critical-CSS-Extraktion ist knifflig

Einfache Regex-Ansätze scheitern an: - CSS-Kommentaren (können den Selektor-Abgleich verunreinigen) - Regeln innerhalb von Media Queries (müssen in ihren Blöcken bleiben) - :root-Media-Queries (Variablenänderungen betreffen alles)

3. Der media="print"-Trick funktioniert

Nicht-kritisches CSS mit media="print" onload="this.media='all'" zu laden, ist ein legitimer Weg, das Laden von Stylesheets ohne JavaScript-Komplexität zu verzögern.

4. CLS-Debugging erfordert den Filmstreifen

Die Filmstreifen-Ansicht von Lighthouse zeigt genau, wann Verschiebungen auftreten. Ohne sie raten Sie nur. Das als „verschoben” markierte Element könnte lediglich ein Symptom sein — suchen Sie nach dem, was die Verschiebung verursacht.

5. 100vh-Hero-Bereiche haben Vorteile jenseits der Ästhetik

Wenn Ihr Hero den gesamten Viewport ausfüllt, kann Inhalt unterhalb des sichtbaren Bereichs nicht zum CLS beitragen, da er erst gemessen wird, wenn der Benutzer scrollt. Das fügt sich in redaktionelle Gestaltungsprinzipien ein — bildschirmfüllende Hero-Bereiche erzeugen visuelle Wirkung und einen Performance-Vorteil zugleich.


Der Tech-Stack

  • Backend: FastAPI + Jinja2
  • Frontend: HTMX + Alpine.js (selbst gehostet, kein CDN)
  • CSS: Reines CSS mit Custom Properties, keine Präprozessoren
  • Optimierung: Eigenes Python-Skript zur Critical-CSS-Extraktion
  • Hosting: Railway mit GZip-Komprimierung und immutablem Caching

Der gesamte Stack läuft ohne Build-Werkzeuge — kein webpack, kein Vite, kein Bundler. Das No-Build-Manifest dokumentiert sämtliche Kennzahlen und Kompromisse dieses Ansatzes. Diese Schlichtheit machte auch das Designsystem leichter prüfbar: Zwischen dem geschriebenen CSS und dem, was im Browser ankommt, liegen weniger Schichten. Dieser Beitrag gehört zum Design-Engineering-Hub, der die Schnittstelle von Handwerk und Umsetzung über die gesamte Website hinweg bündelt.


Häufige Fragen

Was ist ein guter Lighthouse-Wert?

Google betrachtet einen Wert ab 90 als gut. In allen vier Kategorien (Performance, Barrierefreiheit, Best Practices, SEO) 100 Punkte zu erreichen, ist auf Mobilgeräten selten: schwankende Netzgeschwindigkeiten, CPU-Drosselung und Skripte von Drittanbietern machen es schwer, jede Kennzahl zu kontrollieren. Die meisten Produktivsysteme erreichen mobil zwischen 50 und 80 Punkten.

Wodurch entsteht Cumulative Layout Shift (CLS)?

CLS misst, wie stark sich sichtbare Inhalte nach dem ersten Rendern verschieben. Typische Ursachen sind Bilder ohne explizite Abmessungen, dynamisch eingefügte Inhalte, Webfonts, die gegen Fallback-Schriften getauscht werden (FOIT/FOUT) — und, wie dieser Beitrag zeigt, CSS Custom Properties, deren Wert sich ändert, sobald ein verzögert geladenes Stylesheet eintrifft. Google bewertet einen CLS über 0,1 als „schlecht” und unter 0,1 als „gut”.

Wie verbessert Critical CSS die Lighthouse-Performance?

Critical CSS extrahiert die Stile, die für den sichtbaren Bereich nötig sind, und bettet sie direkt im <head> des HTML ein. Damit entfällt der render-blockierende Umweg, bei dem der Browser erst ein vollständiges Stylesheet lädt und parst, bevor er überhaupt etwas zeichnet. Das übrige CSS wird per media="print"-Technik asynchron nachgeladen und vom Browser als nicht blockierend behandelt. Im vorliegenden Fall sank die Render-Verzögerung des LCP-Elements von 2.460 ms auf rund 300 ms.

Lässt sich ein perfekter Lighthouse-Wert ohne Build-Werkzeug erreichen?

Ja. Diese Website nutzt reines CSS ohne Präprozessor, ohne Bundler und ohne Framework — lediglich FastAPI, das Jinja2-Templates ausliefert. Ein Python-Skript übernimmt die Critical-CSS-Extraktion beim Deployment. Ohne Build-Tooling wird das Performance-Audit sogar einfacher, weil zwischen Quell-CSS und dem, was im Browser ankommt, weniger Abstraktionsschichten liegen.

Warum zählt der mobile Lighthouse-Wert mehr als der für Desktop?

Google indexiert mobil zuerst; die Platzierung in der Suche richtet sich also nach der mobilen Version Ihrer Website. Mobile Lighthouse-Audits arbeiten mit CPU-Drosselung (vierfache Verlangsamung) und gedrosseltem Netz (simuliertes langsames 4G) und legen damit Performance-Probleme offen, die auf dem Desktop unsichtbar bleiben. Eine Website mit 100 Punkten auf dem Desktop liegt mobil unter diesen Bedingungen häufig 20 bis 30 Punkte darunter.


Verwendete Werkzeuge

  • Lighthouse (Chrome DevTools)
  • Lighthouse-Filmstreifen-Ansicht für CLS-Debugging
  • grep und regex101.com für CSS-Analyse
  • Python für das Critical-CSS-Extraktionsskript
  • iTerm 2
  • Claude Code CLI mit Opus 4.5 und einer Menge ultrathink

Verwandte Beiträge

SwiftUI-Performance und Interop in iOS 27

Wie SwiftUI in iOS 27 Lazy-Stack-Scrolling, GPU-Shader-Effekte und AppKit/UIKit-Interop handhabt – aus drei offiziellen …

24 Min. Lesezeit

Was Apples Performance-Team im WWDC26-Lab gesagt hat

Apples Power-&-Performance-Team beantwortete auf der WWDC26 Entwicklerfragen. Praxisnahe Hinweise zu MetricKit, Power Pr…

18 Min. Lesezeit

Der Agent-Stack für Design Engineers

Design Engineers brauchen eine Agent-Infrastruktur, die visuelle Konsistenz, Typografie und Farb-Compliance durchsetzt –…

14 Min. Lesezeit