← Alle Beitrage

Von 76 auf 100: Ein perfekter Lighthouse-Score

TL;DR: 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. Die Reise enthüllte subtile CSS-Ladeprobleme, Regex-Bugs und eine besonders heimtückische CSS-Variablen-Überschreibung, 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 initiale Lighthouse Mobile-Audit:

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

Diese CLS-Zahl ist brutal. Google betrachtet alles über 0,1 als „schlecht”. Bei 0,493 sprang die Seite während des Ladens sichtbar umher.


Phase 1: Schnelle Accessibility-Verbesserungen

Bevor ich die Performance anging, bereinigten ich Accessibility-Probleme, um eine Baseline zu etablieren:

Formular-Labels

Dekorative Spans wurden zu ordnungsgemäßen <label>-Elementen geändert:

<!-- 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ß). Erhöht auf --color-text-secondary (65% Deckkraft), um die WCAG AA 4,5:1 Kontrastanforderungen zu erfüllen.

Redundanter Alt-Text

Social-Media-Icons hatten Alt-Text wie „LinkedIn icon”, obwohl sie sich innerhalb von Links befanden, die bereits aria-labels hatten. Geändert zu alt="" mit aria-hidden="true", um Wiederholungen durch Screenreader zu vermeiden.

Identischer Link-Text

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

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

Ergebnis: Accessibility stieg von 91 auf 100.


Phase 2: Das Render-Blocking CSS-Problem

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

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

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

Die Lösung: Critical CSS + Async Loading

Schritt 1: Critical (above-the-fold) CSS in eine separate Datei extrahieren, die inline im <head> eingebunden wird.

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 sagt dem Browser „dieses Stylesheet wird nicht für das Bildschirm-Rendering benötigt”, sodass es nicht blockiert. Sobald es geladen ist, schaltet der onload-Handler es auf media="all" um.

Das Critical CSS Extraktionsskript

Ich schrieb ein Python-Skript, um automatisch Critical CSS für Above-the-Fold-Komponenten zu extrahieren:

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: LCP Element Render Delay sank von 2.460ms auf ~300ms.


Phase 3: Der CLS-Alptraum beginnt

Nach der Implementierung von Async CSS wurde CLS tatsächlich schlimmer – es stieg auf 0,119. Lighthouse zeigte <main> als das verschiebende Element an.

Bug #1: CSS-Kommentare verschmutzten Regex

Das Extraktionsskript verwendete einen Regex, um Selektoren zu matchen:

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

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

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

Würde dazu führen, dass der Regex von „Hero Section” bis zur öffnenden Klammer matcht, was fehlerhafte Selektoren erzeugte, die die Präfix-Prüfung nicht bestanden. Kritische .hero-Stile wurden stillschweigend verworfen.

Fix: Kommentare vor allen Regex-Operationen entfernen:

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

Bug #2: Media Query-Regeln wurden als eigenständig 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 der obersten 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 die gleichen Regeln hatte. Aber auf dem Desktop wurde die mobile Überschreibung fälschlicherweise angewendet.

Fix: Media Query-Positionen tracken und Regeln innerhalb davon ü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

CLS war immer noch bei 0,116. Theorie: Wenn nichts unterhalb des Folds beim initialen Paint sichtbar ist, kann es nicht zu CLS beitragen.

Hero von 85vh auf 100vh geändert:

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

Ergebnis: Keine Änderung. CLS immer noch 0,116. Die Verschiebung passierte innerhalb des Viewports.


Phase 5: Die rauchende Waffe

Der Lighthouse-Filmstrip zeigte, wie der Hero-Text zwischen Frames sichtbar seine Position wechselte. Nicht fadeend – horizontal bewegend.

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

Dann fand ich es:

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

Die Sequenz auf Mobilgeräten:

  1. Critical CSS lädt: --gutter: 48px
  2. Hero wird mit 48px Seitenpadding gerendert
  3. Vollständiges CSS lädt asynchron
  4. Media Query setzt --gutter: 24px
  5. Hero-Padding schrumpft von 48px auf 24px
  6. Text fließt um und verschiebt sich = CLS 0,116

Der Fix

: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 Critical CSS:

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

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

Mobilgeräte rendern von Anfang an mit dem korrekten 24px-Gutter. Wenn das vollständige CSS lädt, ist --gutter bereits 24px – keine Änderung, keine Verschiebung.


Phase 6: CSP und Alpine.js

Noch ein Problem: Alpine.js warf Konsolenfehler wegen Content Security Policy-Verletzungen. Alpine verwendet intern dynamische Expression-Evaluation.

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

Die 'unsafe-eval'-Direktive ist für Alpine.js’s Expression-Parsing erforderlich. Die Alternative ist die Verwendung von Alpines CSP-kompatiblem Build, der einige Einschränkungen hat.


Endergebnisse

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

Der Beweis

Desktop-Audit mit perfekten Scores in allen vier Kategorien:

Lighthouse desktop audit showing 100/100/100/100 scores

Mobile-Audit – das wichtigste – erreicht ebenfalls perfekte Werte:

Lighthouse mobile audit showing 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 von DevTools unsichtbar – man sieht nur den finalen Wert. Wenn Ihr Critical CSS keine Variablen-Überschreibungen enthält, wird das Layout sich verschieben, wenn das vollständige Stylesheet lädt.

2. Critical CSS-Extraktion ist knifflig

Einfache Regex-Ansätze scheitern bei: - CSS-Kommentaren (können Selektor-Matching verschmutzen) - 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 Filmstrip

Lighthouse’s Filmstrip-Ansicht zeigt genau, wann Verschiebungen auftreten. Ohne sie rät man. Das als „verschiebend” markierte Element könnte nur ein Symptom sein – suchen Sie nach dem, was die Verschiebung verursacht.

5. 100vh Hero-Sections haben Vorteile über Ästhetik hinaus

Wenn Ihr Hero den Viewport ausfüllt, kann Below-the-Fold-Content nicht zu CLS beitragen, weil er erst gemessen wird, wenn der Benutzer scrollt.


Der Stack

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

Verwendete Tools

  • Lighthouse (Chrome DevTools)
  • Lighthouse Filmstrip-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 viel ultrathink